From 48a14667be267090beab3e5f41bd0ade03dbe065 Mon Sep 17 00:00:00 2001 From: Andy Eschbacher Date: Thu, 16 Jul 2026 06:08:02 -0400 Subject: [PATCH 1/4] feat: new read modalities --- README.md | 8 +- docs/cli/tskflwctl_audit.md | 2 + docs/cli/tskflwctl_audit_info.md | 39 +++ docs/cli/tskflwctl_audit_path.md | 39 +++ docs/cli/tskflwctl_audit_show.md | 9 +- docs/cli/tskflwctl_epic.md | 1 + docs/cli/tskflwctl_epic_path.md | 39 +++ docs/cli/tskflwctl_epic_show.md | 9 +- docs/cli/tskflwctl_task.md | 2 + docs/cli/tskflwctl_task_info.md | 39 +++ docs/cli/tskflwctl_task_path.md | 39 +++ docs/cli/tskflwctl_task_show.md | 9 +- internal/cli/audit.go | 84 ++++++- internal/cli/bodyscope.go | 40 +++ internal/cli/entityreads_test.go | 142 +++++++++++ internal/cli/epic.go | 46 +++- internal/cli/integration_golden_test.go | 82 +++++-- internal/cli/render/info_test.go | 32 +++ internal/cli/render/render.go | 81 ++++++- internal/cli/task.go | 104 +++++++- internal/cli/taskinfo_test.go | 156 ++++++++++++ .../golden/audit_findings_json.golden | 2 +- .../golden/audit_findings_open_json.golden | 2 +- .../testdata/golden/audit_info_json.golden | 1 + .../testdata/golden/audit_path_json.golden | 1 + .../cli/testdata/golden/board_json.golden | 2 +- .../cli/testdata/golden/epic_list_json.golden | 2 +- .../cli/testdata/golden/epic_path_json.golden | 1 + .../cli/testdata/golden/epic_show_json.golden | 2 +- internal/cli/testdata/golden/lint_json.golden | 2 +- .../cli/testdata/golden/schema_json.golden | 2 +- .../testdata/golden/schema_jsonschema.golden | 189 ++++++++++++++- .../testdata/golden/schema_task_json.golden | 2 +- .../cli/testdata/golden/status_json.golden | 2 +- .../cli/testdata/golden/task_info_json.golden | 1 + .../cli/testdata/golden/task_list_json.golden | 2 +- .../cli/testdata/golden/task_path_json.golden | 1 + .../cli/testdata/golden/task_show_json.golden | 2 +- .../testdata/golden/template_list_json.golden | 2 +- .../golden/template_show_security_json.golden | 2 +- internal/core/service_audit.go | 6 + internal/core/service_epic.go | 6 + internal/core/service_epic_test.go | 3 + internal/core/service_task.go | 6 + internal/core/store.go | 10 + internal/domain/body.go | 157 ++++++++++++ internal/domain/body_test.go | 133 ++++++++++ internal/store/paths.go | 15 ++ internal/wire/dto.go | 60 +++++ internal/wire/envelopes.go | 41 ++++ internal/wire/envelopes_test.go | 7 + internal/wire/schema_comments.json | 8 + internal/wire/wire.go | 9 +- ...-aware-body-mutation-and-metadata-reads.md | 229 ++++++++++++++++++ ...when-anchored-outside-the-planning-tree.md | 56 +++++ 55 files changed, 1904 insertions(+), 64 deletions(-) create mode 100644 docs/cli/tskflwctl_audit_info.md create mode 100644 docs/cli/tskflwctl_audit_path.md create mode 100644 docs/cli/tskflwctl_epic_path.md create mode 100644 docs/cli/tskflwctl_task_info.md create mode 100644 docs/cli/tskflwctl_task_path.md create mode 100644 internal/cli/bodyscope.go create mode 100644 internal/cli/entityreads_test.go create mode 100644 internal/cli/render/info_test.go create mode 100644 internal/cli/taskinfo_test.go create mode 100644 internal/cli/testdata/golden/audit_info_json.golden create mode 100644 internal/cli/testdata/golden/audit_path_json.golden create mode 100644 internal/cli/testdata/golden/epic_path_json.golden create mode 100644 internal/cli/testdata/golden/task_info_json.golden create mode 100644 internal/cli/testdata/golden/task_path_json.golden create mode 100644 internal/domain/body.go create mode 100644 internal/domain/body_test.go create mode 100644 internal/store/paths.go create mode 100644 planning/tasks/6fpfcecdymca-structure-aware-body-mutation-and-metadata-reads.md create mode 100644 planning/tasks/6fpfcecfygt7-clearer-error-and-walk-up-discovery-when-anchored-outside-the-planning-tree.md diff --git a/README.md b/README.md index 0f1a99d..48648b9 100644 --- a/README.md +++ b/README.md @@ -87,9 +87,15 @@ tskflwctl audit new auth --template security # pick a body scaffold (default|se # read tskflwctl task list # active tasks (--all / --status / --epic / --tag) tskflwctl task list --revisit-due # deferred tasks whose snooze date has arrived -tskflwctl task show +tskflwctl task show # metadata + body (--section / --frontmatter-only to narrow) +tskflwctl task info --json # token-cheap metadata: path, status, epic, ac:{checked,total} (no body) +tskflwctl task path # just the absolute file path — $EDITOR "$(tskflwctl task path )" tskflwctl epic list # rollup: done/total per epic +tskflwctl epic show --section goal # epic body section (or --frontmatter-only); epic path for the file tskflwctl audit list # open audits (--all / --closed / --deferred) +tskflwctl audit show --section findings # audit body section (or --frontmatter-only) +tskflwctl audit info --json # token-cheap: path, bucket, findings:{total,open,in_progress,done,dropped} +tskflwctl audit path # just the absolute file path (like task path) tskflwctl audit findings --status open --effort XS,S --json # query findings across audits tskflwctl audit lint # validate finding status vocab + missing status + bucket↔state tskflwctl schema # the tool's contract for agents (statuses, fields, codes) diff --git a/docs/cli/tskflwctl_audit.md b/docs/cli/tskflwctl_audit.md index ae7059a..80cbda3 100644 --- a/docs/cli/tskflwctl_audit.md +++ b/docs/cli/tskflwctl_audit.md @@ -30,9 +30,11 @@ Work with code audits * [tskflwctl audit defer](tskflwctl_audit_defer.md) - Move audit(s) to deferred/ * [tskflwctl audit edit](tskflwctl_audit_edit.md) - Open an audit in your editor (whole file; re-validated on save) * [tskflwctl audit findings](tskflwctl_audit_findings.md) - Query findings across audits (or one) by status/effort/urgency/component +* [tskflwctl audit info](tskflwctl_audit_info.md) - Show an audit's metadata + file path + finding tally (no body) * [tskflwctl audit lint](tskflwctl_audit_lint.md) - Validate audit findings (status vocabulary, missing status, bucket↔state) * [tskflwctl audit list](tskflwctl_audit_list.md) - List audits (open by default) * [tskflwctl audit new](tskflwctl_audit_new.md) - Create a new audit (open bucket, scaffolded findings) +* [tskflwctl audit path](tskflwctl_audit_path.md) - Print the absolute path to an audit's file * [tskflwctl audit reopen](tskflwctl_audit_reopen.md) - Move audit(s) back to open/ * [tskflwctl audit show](tskflwctl_audit_show.md) - Show an audit's metadata and body diff --git a/docs/cli/tskflwctl_audit_info.md b/docs/cli/tskflwctl_audit_info.md new file mode 100644 index 0000000..94e29c8 --- /dev/null +++ b/docs/cli/tskflwctl_audit_info.md @@ -0,0 +1,39 @@ +## tskflwctl audit info + +Show an audit's metadata + file path + finding tally (no body) + +``` +tskflwctl audit info [flags] +``` + +### Examples + +``` + tskflwctl audit show 2026-06-20-api-gateway --frontmatter-only + tskflwctl audit info 2026-06-20-api-gateway --json +``` + +### Options + +``` + -h, --help help for info +``` + +### Options inherited from parent commands + +``` + -C, --chdir string anchor to the planning repo at this path + --color string colorize output: auto|always|never (default "auto") + --dry-run preview the mutation without writing (validation still runs) + --json machine-readable JSON output + --no-color disable colored output (alias for --color=never) + --no-input never prompt; missing required input is an error (for scripts/agents; also TSKFLW_NO_INPUT) + --no-pager do not pipe long human output through a pager + --paginate page long human output through $PAGER (on a TTY), even if disabled in config + --theme string color theme name (overrides TSKFLW_THEME and [theme].name in config) +``` + +### SEE ALSO + +* [tskflwctl audit](tskflwctl_audit.md) - Work with code audits + diff --git a/docs/cli/tskflwctl_audit_path.md b/docs/cli/tskflwctl_audit_path.md new file mode 100644 index 0000000..1655a11 --- /dev/null +++ b/docs/cli/tskflwctl_audit_path.md @@ -0,0 +1,39 @@ +## tskflwctl audit path + +Print the absolute path to an audit's file + +``` +tskflwctl audit path [flags] +``` + +### Examples + +``` + tskflwctl audit path 2026-06-20-api-gateway + $EDITOR "$(tskflwctl audit path 2026-06-20-api-gateway)" +``` + +### Options + +``` + -h, --help help for path +``` + +### Options inherited from parent commands + +``` + -C, --chdir string anchor to the planning repo at this path + --color string colorize output: auto|always|never (default "auto") + --dry-run preview the mutation without writing (validation still runs) + --json machine-readable JSON output + --no-color disable colored output (alias for --color=never) + --no-input never prompt; missing required input is an error (for scripts/agents; also TSKFLW_NO_INPUT) + --no-pager do not pipe long human output through a pager + --paginate page long human output through $PAGER (on a TTY), even if disabled in config + --theme string color theme name (overrides TSKFLW_THEME and [theme].name in config) +``` + +### SEE ALSO + +* [tskflwctl audit](tskflwctl_audit.md) - Work with code audits + diff --git a/docs/cli/tskflwctl_audit_show.md b/docs/cli/tskflwctl_audit_show.md index fb99726..feca379 100644 --- a/docs/cli/tskflwctl_audit_show.md +++ b/docs/cli/tskflwctl_audit_show.md @@ -10,14 +10,17 @@ tskflwctl audit show [flags] ``` tskflwctl audit show 2026-06-20-api-gateway - tskflwctl audit show # pick from a list + tskflwctl audit show 2026-06-20-api-gateway --section findings + tskflwctl audit show 2026-06-20-api-gateway --frontmatter-only ``` ### Options ``` - -h, --help help for show - --raw print the raw markdown body (skip rendering) + --frontmatter-only show only the metadata, skipping the body + -h, --help help for show + --raw print the raw markdown body (skip rendering) + --section string show only the body section whose heading matches this name (e.g. acceptance, progress) ``` ### Options inherited from parent commands diff --git a/docs/cli/tskflwctl_epic.md b/docs/cli/tskflwctl_epic.md index 9cbac15..ac9eae6 100644 --- a/docs/cli/tskflwctl_epic.md +++ b/docs/cli/tskflwctl_epic.md @@ -29,6 +29,7 @@ Work with epics * [tskflwctl epic list](tskflwctl_epic_list.md) - List epics with task rollup * [tskflwctl epic move](tskflwctl_epic_move.md) - Transition epic(s) to (active|retired|deprecated) * [tskflwctl epic new](tskflwctl_epic_new.md) - Create a new epic (auto-numbered NN-slug) +* [tskflwctl epic path](tskflwctl_epic_path.md) - Print the absolute path to an epic's file * [tskflwctl epic set](tskflwctl_epic_set.md) - Set one or more epic frontmatter fields (validated, single atomic write) * [tskflwctl epic show](tskflwctl_epic_show.md) - Show an epic and the tasks under it diff --git a/docs/cli/tskflwctl_epic_path.md b/docs/cli/tskflwctl_epic_path.md new file mode 100644 index 0000000..bd4d561 --- /dev/null +++ b/docs/cli/tskflwctl_epic_path.md @@ -0,0 +1,39 @@ +## tskflwctl epic path + +Print the absolute path to an epic's file + +``` +tskflwctl epic path [flags] +``` + +### Examples + +``` + tskflwctl epic path 01-api-gateway + $EDITOR "$(tskflwctl epic path 01-api-gateway)" +``` + +### Options + +``` + -h, --help help for path +``` + +### Options inherited from parent commands + +``` + -C, --chdir string anchor to the planning repo at this path + --color string colorize output: auto|always|never (default "auto") + --dry-run preview the mutation without writing (validation still runs) + --json machine-readable JSON output + --no-color disable colored output (alias for --color=never) + --no-input never prompt; missing required input is an error (for scripts/agents; also TSKFLW_NO_INPUT) + --no-pager do not pipe long human output through a pager + --paginate page long human output through $PAGER (on a TTY), even if disabled in config + --theme string color theme name (overrides TSKFLW_THEME and [theme].name in config) +``` + +### SEE ALSO + +* [tskflwctl epic](tskflwctl_epic.md) - Work with epics + diff --git a/docs/cli/tskflwctl_epic_show.md b/docs/cli/tskflwctl_epic_show.md index f32ca05..44fcaa4 100644 --- a/docs/cli/tskflwctl_epic_show.md +++ b/docs/cli/tskflwctl_epic_show.md @@ -10,14 +10,17 @@ tskflwctl epic show [flags] ``` tskflwctl epic show 01-api-gateway - tskflwctl epic show # pick from a list + tskflwctl epic show 01-api-gateway --section goal + tskflwctl epic show 01-api-gateway --frontmatter-only ``` ### Options ``` - -h, --help help for show - --raw print the raw markdown body (skip rendering) + --frontmatter-only show only the metadata, skipping the body + -h, --help help for show + --raw print the raw markdown body (skip rendering) + --section string show only the body section whose heading matches this name (e.g. acceptance, progress) ``` ### Options inherited from parent commands diff --git a/docs/cli/tskflwctl_task.md b/docs/cli/tskflwctl_task.md index b2e6f90..4f6baed 100644 --- a/docs/cli/tskflwctl_task.md +++ b/docs/cli/tskflwctl_task.md @@ -30,10 +30,12 @@ Work with tasks * [tskflwctl task defer](tskflwctl_task_defer.md) - Move task(s) to deferred (optionally with a revisit date) * [tskflwctl task deprecate](tskflwctl_task_deprecate.md) - Move task(s) to deprecated * [tskflwctl task edit](tskflwctl_task_edit.md) - Open a task in your editor (whole file; re-validated on save) +* [tskflwctl task info](tskflwctl_task_info.md) - Show a task's metadata + file path + acceptance tally (no body) * [tskflwctl task list](tskflwctl_task_list.md) - List tasks (active by default) * [tskflwctl task move](tskflwctl_task_move.md) - Transition task(s) to (generic escape hatch) * [tskflwctl task new](tskflwctl_task_new.md) - Create a new task (validated, handoff-ready scaffold) * [tskflwctl task next](tskflwctl_task_next.md) - Move task(s) to next-up +* [tskflwctl task path](tskflwctl_task_path.md) - Print the absolute path to a task's file * [tskflwctl task ready](tskflwctl_task_ready.md) - Move task(s) to ready-to-start * [tskflwctl task rename](tskflwctl_task_rename.md) - Re-title a task (new slug, id kept) and cascade its inbound body links * [tskflwctl task set](tskflwctl_task_set.md) - Set one or more frontmatter fields (validated, single atomic write) diff --git a/docs/cli/tskflwctl_task_info.md b/docs/cli/tskflwctl_task_info.md new file mode 100644 index 0000000..79735db --- /dev/null +++ b/docs/cli/tskflwctl_task_info.md @@ -0,0 +1,39 @@ +## tskflwctl task info + +Show a task's metadata + file path + acceptance tally (no body) + +``` +tskflwctl task info [flags] +``` + +### Examples + +``` + tskflwctl task info add-retry-backoff + tskflwctl task info add-retry-backoff --json +``` + +### Options + +``` + -h, --help help for info +``` + +### Options inherited from parent commands + +``` + -C, --chdir string anchor to the planning repo at this path + --color string colorize output: auto|always|never (default "auto") + --dry-run preview the mutation without writing (validation still runs) + --json machine-readable JSON output + --no-color disable colored output (alias for --color=never) + --no-input never prompt; missing required input is an error (for scripts/agents; also TSKFLW_NO_INPUT) + --no-pager do not pipe long human output through a pager + --paginate page long human output through $PAGER (on a TTY), even if disabled in config + --theme string color theme name (overrides TSKFLW_THEME and [theme].name in config) +``` + +### SEE ALSO + +* [tskflwctl task](tskflwctl_task.md) - Work with tasks + diff --git a/docs/cli/tskflwctl_task_path.md b/docs/cli/tskflwctl_task_path.md new file mode 100644 index 0000000..4d95d0a --- /dev/null +++ b/docs/cli/tskflwctl_task_path.md @@ -0,0 +1,39 @@ +## tskflwctl task path + +Print the absolute path to a task's file + +``` +tskflwctl task path [flags] +``` + +### Examples + +``` + tskflwctl task path add-retry-backoff + $EDITOR "$(tskflwctl task path add-retry-backoff)" +``` + +### Options + +``` + -h, --help help for path +``` + +### Options inherited from parent commands + +``` + -C, --chdir string anchor to the planning repo at this path + --color string colorize output: auto|always|never (default "auto") + --dry-run preview the mutation without writing (validation still runs) + --json machine-readable JSON output + --no-color disable colored output (alias for --color=never) + --no-input never prompt; missing required input is an error (for scripts/agents; also TSKFLW_NO_INPUT) + --no-pager do not pipe long human output through a pager + --paginate page long human output through $PAGER (on a TTY), even if disabled in config + --theme string color theme name (overrides TSKFLW_THEME and [theme].name in config) +``` + +### SEE ALSO + +* [tskflwctl task](tskflwctl_task.md) - Work with tasks + diff --git a/docs/cli/tskflwctl_task_show.md b/docs/cli/tskflwctl_task_show.md index 8a7050c..2cf5296 100644 --- a/docs/cli/tskflwctl_task_show.md +++ b/docs/cli/tskflwctl_task_show.md @@ -10,14 +10,17 @@ tskflwctl task show [flags] ``` tskflwctl task show add-retry-backoff - tskflwctl task show # pick from a list + tskflwctl task show add-retry-backoff --section acceptance + tskflwctl task show add-retry-backoff --frontmatter-only ``` ### Options ``` - -h, --help help for show - --raw print the raw markdown body (skip rendering) + --frontmatter-only show only the metadata, skipping the body + -h, --help help for show + --raw print the raw markdown body (skip rendering) + --section string show only the body section whose heading matches this name (e.g. acceptance, progress) ``` ### Options inherited from parent commands diff --git a/internal/cli/audit.go b/internal/cli/audit.go index 9cff5a3..d9185a9 100644 --- a/internal/cli/audit.go +++ b/internal/cli/audit.go @@ -29,6 +29,8 @@ func newAuditCmd(app *App) *cobra.Command { newAuditNewCmd(app), newAuditListCmd(app), newAuditShowCmd(app), + newAuditInfoCmd(app), + newAuditPathCmd(app), newAuditEditCmd(app), newAuditAppendCmd(app), newAuditFindingsCmd(app), @@ -223,11 +225,15 @@ func newAuditLintCmd(app *App) *cobra.Command { } func newAuditShowCmd(app *App) *cobra.Command { - var raw bool + var ( + raw bool + section string + fmOnly bool + ) cmd := &cobra.Command{ Use: "show ", Short: "Show an audit's metadata and body", - Example: " tskflwctl audit show 2026-06-20-api-gateway\n tskflwctl audit show # pick from a list", + Example: " tskflwctl audit show 2026-06-20-api-gateway\n tskflwctl audit show 2026-06-20-api-gateway --section findings\n tskflwctl audit show 2026-06-20-api-gateway --frontmatter-only", Args: cobra.MaximumNArgs(1), // bare → picker on a TTY; non-interactive needs the slug Annotations: map[string]string{"safety": "read-only"}, ValidArgsFunction: app.completeAuditSlugs, @@ -240,21 +246,87 @@ func newAuditShowCmd(app *App) *cobra.Command { if err != nil { return err } + // --section / --frontmatter-only narrow the audit's markdown body only; the + // metadata + finding tree always show. Parse findings from the FULL body so + // the tree is unaffected by a narrowed view. + findings := domain.ParseFindings(body) + body, err = narrowBody("audit", slug, body, section, fmOnly) + if err != nil { + return err + } if app.JSON { return render.AuditShowJSON(app.Out, audit, body) } - // Parse findings from the RAW body for the status-grouped tree; the body - // passed to the renderer is the rendered (glamour/raw) markdown. - findings := domain.ParseFindings(body) return app.paged(func(w io.Writer) error { - return render.AuditShowHuman(w, app.Style, audit, findings, render.RenderBody(app.Style, body, app.markdownStyle, raw)) + rendered := "" + if body != "" { // --frontmatter-only → no body render (and no trailing blank line) + rendered = render.RenderBody(app.Style, body, app.markdownStyle, raw) + } + return render.AuditShowHuman(w, app.Style, audit, findings, rendered) }) }, } cmd.Flags().BoolVar(&raw, "raw", false, "print the raw markdown body (skip rendering)") + addBodyScopeFlags(cmd, §ion, &fmOnly) return cmd } +// newAuditInfoCmd is the token-cheap audit metadata read: file path, bucket, and +// the finding disposition tally (the audit analogue of `task info`'s acceptance +// tally), WITHOUT the body. `--json` is the machine path. +func newAuditInfoCmd(app *App) *cobra.Command { + return &cobra.Command{ + Use: "info ", + Short: "Show an audit's metadata + file path + finding tally (no body)", + Example: " tskflwctl audit show 2026-06-20-api-gateway --frontmatter-only\n tskflwctl audit info 2026-06-20-api-gateway --json", + Args: cobra.MaximumNArgs(1), + Annotations: map[string]string{"safety": "read-only"}, + ValidArgsFunction: app.completeAuditSlugs, + RunE: func(_ *cobra.Command, args []string) error { + slug, err := app.resolveOne(args, "specify an audit", "no audits available", "Audit", app.auditOptions) + if err != nil { + return err + } + // ShowAudit populates the disposition tally on load (parseAudit), so no + // re-parse is needed for the counts. + audit, _, err := app.Svc.ShowAudit(slug) + if err != nil { + return err + } + path := absPath(audit.Path) + if app.JSON { + return render.AuditInfoJSON(app.Out, audit, path) + } + render.AuditInfoHuman(app.Out, app.Style, audit, path) + return nil + }, + } +} + +// newAuditPathCmd prints just the absolute path to an audit's file — the audit +// counterpart to `task path`, parse-free so it works on a broken file too. +func newAuditPathCmd(app *App) *cobra.Command { + return &cobra.Command{ + Use: "path ", + Short: "Print the absolute path to an audit's file", + Example: " tskflwctl audit path 2026-06-20-api-gateway\n $EDITOR \"$(tskflwctl audit path 2026-06-20-api-gateway)\"", + Args: cobra.MaximumNArgs(1), + Annotations: map[string]string{"safety": "read-only"}, + ValidArgsFunction: app.completeAuditSlugs, + RunE: func(_ *cobra.Command, args []string) error { + slug, err := app.resolveOne(args, "specify an audit", "no audits available", "Audit", app.auditOptions) + if err != nil { + return err + } + p, err := app.Svc.AuditPath(slug) + if err != nil { + return err + } + return emitPath(app, absPath(p)) + }, + } +} + func newAuditMoveCmd(app *App, use, short string, to domain.AuditBucket) *cobra.Command { return &cobra.Command{ Use: use + " ...", diff --git a/internal/cli/bodyscope.go b/internal/cli/bodyscope.go new file mode 100644 index 0000000..1828014 --- /dev/null +++ b/internal/cli/bodyscope.go @@ -0,0 +1,40 @@ +package cli + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/andy-esch/taskflow/internal/domain" +) + +// addBodyScopeFlags wires the shared read-narrowing flags onto a `show` command: +// --section (one named body section) and --frontmatter-only (drop the body). They +// are mutually exclusive. Task/epic/audit show all use these so the surface can't +// drift between entities. +func addBodyScopeFlags(cmd *cobra.Command, section *string, fmOnly *bool) { + cmd.Flags().StringVar(section, "section", "", + "show only the body section whose heading matches this name (e.g. acceptance, progress)") + cmd.Flags().BoolVar(fmOnly, "frontmatter-only", false, + "show only the metadata, skipping the body") + cmd.MarkFlagsMutuallyExclusive("section", "frontmatter-only") +} + +// narrowBody applies --section / --frontmatter-only to a body: "" for +// frontmatter-only, the named section slice for --section (ErrNotFound when no +// heading matches), else the body unchanged. These flags narrow only the free-text +// markdown body — the metadata block (and, for epics/audits, the roster/finding +// tree) is always shown. kind/id shape the not-found message. +func narrowBody(kind, id, body, section string, fmOnly bool) (string, error) { + switch { + case fmOnly: + return "", nil + case section != "": + sec, ok := domain.Section(body, section) + if !ok { + return "", fmt.Errorf("%w: %s %q has no section matching %q", domain.ErrNotFound, kind, id, section) + } + return sec, nil + } + return body, nil +} diff --git a/internal/cli/entityreads_test.go b/internal/cli/entityreads_test.go new file mode 100644 index 0000000..f8357ab --- /dev/null +++ b/internal/cli/entityreads_test.go @@ -0,0 +1,142 @@ +package cli + +import ( + "encoding/json" + "errors" + "path/filepath" + "strings" + "testing" + + "github.com/andy-esch/taskflow/internal/domain" + "github.com/andy-esch/taskflow/internal/testutil" +) + +// canonAbs is the canonical absolute path (symlinks resolved, as discovery does). +func canonAbs(t *testing.T, p string) string { + t.Helper() + r, err := filepath.EvalSymlinks(p) + if err != nil { + t.Fatal(err) + } + return r +} + +// --- epic --- + +func TestEpicPath(t *testing.T) { + root := setupEpicRepo(t) + out := runRoot(t, "-C", root, "epic", "path", "demo") + want := canonAbs(t, filepath.Join(root, "epics", "demo.md")) + if strings.TrimSpace(out) != want { + t.Errorf("epic path = %q, want %q", strings.TrimSpace(out), want) + } +} + +func TestEpicShow_FrontmatterOnly(t *testing.T) { + root := setupEpicRepo(t) + out := runRoot(t, "-C", root, "epic", "show", "demo", "--frontmatter-only") + if strings.Contains(out, "# Demo Epic") { + t.Errorf("--frontmatter-only must drop the epic body:\n%s", out) + } + if !strings.Contains(out, "demo") { // metadata + roster still shown + t.Errorf("--frontmatter-only should still show epic metadata/roster:\n%s", out) + } + if strings.HasSuffix(out, "\n\n") { // no stray blank line under the roster + t.Errorf("epic --frontmatter-only must not leave a trailing blank line:\n%q", out) + } +} + +// audit --frontmatter-only drops the body (keeping the finding tree) with no stray +// trailing blank line. +func TestAuditShow_FrontmatterOnly(t *testing.T) { + root := setupAuditRepo(t) + out := runRoot(t, "-C", root, "audit", "show", "o", "--frontmatter-only") + if strings.HasSuffix(out, "\n\n") { + t.Errorf("audit --frontmatter-only must not leave a trailing blank line:\n%q", out) + } +} + +func TestEpicShow_SectionNotFound(t *testing.T) { + root := setupEpicRepo(t) + if _, err := runRootRC(t, "-C", root, "epic", "show", "demo", "--section", "nope"); !errors.Is(err, domain.ErrNotFound) { + t.Errorf("a missing epic section should wrap ErrNotFound, got %v", err) + } +} + +// --- audit --- + +func TestAuditPath(t *testing.T) { + root := setupAuditRepo(t) + out := runRoot(t, "-C", root, "audit", "path", "o") + want := canonAbs(t, filepath.Join(root, domain.AuditsDir, testutil.TaskID("o")+"-o.md")) + if strings.TrimSpace(out) != want { + t.Errorf("audit path = %q, want %q", strings.TrimSpace(out), want) + } +} + +// audit info reports the finding disposition tally (the audit analogue of the AC +// tally) without the body. Fixture audit "o" has one open finding. +func TestAuditInfo_JSON(t *testing.T) { + root := setupAuditRepo(t) + out := runRoot(t, "-C", root, "--json", "audit", "info", "o") + var env struct { + SchemaVersion string `json:"schema_version"` + AuditInfo struct { + Slug string `json:"slug"` + Bucket string `json:"bucket"` + Path string `json:"path"` + Findings struct { + Total int `json:"total"` + Open int `json:"open"` + InProgress int `json:"in_progress"` + Done int `json:"done"` + Dropped int `json:"dropped"` + } `json:"findings"` + } `json:"audit_info"` + } + if err := json.Unmarshal([]byte(out), &env); err != nil { + t.Fatalf("audit info --json not parseable: %v\n%s", err, out) + } + ai := env.AuditInfo + want := canonAbs(t, filepath.Join(root, domain.AuditsDir, testutil.TaskID("o")+"-o.md")) + if env.SchemaVersion == "" || ai.Slug != "o" || ai.Bucket != "open" || ai.Path != want { + t.Errorf("audit info metadata wrong:\n%s", out) + } + if ai.Findings.Total != 1 || ai.Findings.Open != 1 { + t.Errorf("findings tally = %+v, want {Total:1 Open:1}\n%s", ai.Findings, out) + } +} + +// audit show --section narrows to one body section; the metadata + finding tree +// still render, but other body sections do not. +func TestAuditShow_Section(t *testing.T) { + root := setupAuditRepo(t) + p, out := testutil.AuditFixture(root, "open", "o.md", + "---\nid: "+testutil.TaskID("o")+"\nbucket: open\narea: dispatcher\n---\n## Threat model\n\nboundaries.\n\n## Findings\n\n#### H1. t · **Status:** open\n") + testutil.Write(t, p, out) + res := runRoot(t, "-C", root, "audit", "show", "o", "--section", "findings", "--raw") + if !strings.Contains(res, "## Findings") || !strings.Contains(res, "H1") { + t.Errorf("--section findings should show that section:\n%s", res) + } + if strings.Contains(res, "Threat model") || strings.Contains(res, "boundaries.") { + t.Errorf("--section findings must not show other body sections:\n%s", res) + } +} + +// --- parse-free path (the Q1 follow-up) --- + +// task path resolves even a file whose frontmatter won't parse — exactly when you +// need the path to open and repair it. task show, which parses, must still fail. +func TestTaskPath_BrokenFrontmatter(t *testing.T) { + root := setupRepo(t) + broken := filepath.Join(root, domain.TasksDir, testutil.TaskID("broken")+"-broken.md") + testutil.Write(t, broken, "---\nstatus: ready-to-start\nbad: [unclosed\n---\n# Broken\n") + + out := runRoot(t, "-C", root, "task", "path", "broken") + if strings.TrimSpace(out) != canonAbs(t, broken) { + t.Errorf("task path must resolve a broken-frontmatter file:\ngot %q\nwant %q", strings.TrimSpace(out), canonAbs(t, broken)) + } + if _, err := runRootRC(t, "-C", root, "task", "show", "broken"); err == nil { + t.Error("task show should fail on broken frontmatter (the contrast that motivates a parse-free path)") + } +} diff --git a/internal/cli/epic.go b/internal/cli/epic.go index 31a21b6..2e920b1 100644 --- a/internal/cli/epic.go +++ b/internal/cli/epic.go @@ -17,11 +17,36 @@ func newEpicCmd(app *App) *cobra.Command { cmd := &cobra.Command{Use: "epic", Short: "Work with epics"} cmd.AddCommand( newEpicNewCmd(app), newEpicListCmd(app), newEpicShowCmd(app), + newEpicPathCmd(app), newEpicSetCmd(app), newEpicEditCmd(app), newEpicMoveCmd(app), ) return cmd } +// newEpicPathCmd prints just the absolute path to an epic's file — the epic +// counterpart to `task path`, parse-free so it works on a broken file too. +func newEpicPathCmd(app *App) *cobra.Command { + return &cobra.Command{ + Use: "path ", + Short: "Print the absolute path to an epic's file", + Example: " tskflwctl epic path 01-api-gateway\n $EDITOR \"$(tskflwctl epic path 01-api-gateway)\"", + Args: cobra.MaximumNArgs(1), + Annotations: map[string]string{"safety": "read-only"}, + ValidArgsFunction: app.completeEpicIDs, + RunE: func(_ *cobra.Command, args []string) error { + id, err := app.resolveOne(args, "specify an epic", "no epics available", "Epic", app.epicOptions) + if err != nil { + return err + } + p, err := app.Svc.EpicPath(id) + if err != nil { + return err + } + return emitPath(app, absPath(p)) + }, + } +} + // newEpicSetCmd is the agent face of epic mutation: field-level, atomic, validated, // `--dry-run`-previewable — the epic counterpart to `task set`. Status is absent by // design (it moves via `epic move`); everything else (priority/description/tags, plus @@ -294,11 +319,15 @@ func filterEpicsByStatus(epics []core.EpicSummary, status string) []core.EpicSum } func newEpicShowCmd(app *App) *cobra.Command { - var raw bool + var ( + raw bool + section string + fmOnly bool + ) cmd := &cobra.Command{ Use: "show ", Short: "Show an epic and the tasks under it", - Example: " tskflwctl epic show 01-api-gateway\n tskflwctl epic show # pick from a list", + Example: " tskflwctl epic show 01-api-gateway\n tskflwctl epic show 01-api-gateway --section goal\n tskflwctl epic show 01-api-gateway --frontmatter-only", Args: cobra.MaximumNArgs(1), // bare → picker on a TTY; non-interactive needs the id Annotations: map[string]string{"safety": "read-only"}, ValidArgsFunction: app.completeEpicIDs, @@ -311,14 +340,25 @@ func newEpicShowCmd(app *App) *cobra.Command { if err != nil { return err } + // --section / --frontmatter-only narrow the epic's markdown body only; the + // metadata + task roster always show. + body, err = narrowBody("epic", id, body, section, fmOnly) + if err != nil { + return err + } if app.JSON { return render.EpicShowJSON(app.Out, es.Epic, tasks, body) } return app.paged(func(w io.Writer) error { - return render.EpicShowHuman(w, app.Style, es, tasks, render.RenderBody(app.Style, body, app.markdownStyle, raw)) + rendered := "" + if body != "" { // --frontmatter-only → no body render (and no trailing blank line) + rendered = render.RenderBody(app.Style, body, app.markdownStyle, raw) + } + return render.EpicShowHuman(w, app.Style, es, tasks, rendered) }) }, } cmd.Flags().BoolVar(&raw, "raw", false, "print the raw markdown body (skip rendering)") + addBodyScopeFlags(cmd, §ion, &fmOnly) return cmd } diff --git a/internal/cli/integration_golden_test.go b/internal/cli/integration_golden_test.go index 703b000..4f60383 100644 --- a/internal/cli/integration_golden_test.go +++ b/internal/cli/integration_golden_test.go @@ -1,45 +1,87 @@ package cli -import "testing" +import ( + "path/filepath" + "strings" + "testing" +) // fixtureRepo is the committed, date-stable planning tree the golden snapshots run // against (testdata/planning/). Edits there are intentional and regenerate via // -update. const fixtureRepo = "testdata/planning" +// redactFixtureRoot replaces the machine-specific absolute fixture path with a +// stable placeholder, so byte-golden cases for path-bearing output (task/audit info, +// task/epic/audit path — all emit filepath.Abs of the resolved store path) are +// portable across checkouts, CI, AND platforms instead of embedding one machine's +// repo root. It also folds Windows separators to '/': json.Marshal escapes native +// backslashes as `\\`, so the redactor normalizes those to '/' before matching, and +// resolves the root to its forward-slash form — the golden is one form everywhere. +func redactFixtureRoot(t *testing.T) func(string) string { + t.Helper() + root, err := filepath.Abs(fixtureRepo) + if err != nil { + t.Fatal(err) + } + if resolved, err := filepath.EvalSymlinks(root); err == nil { + root = resolved // discovery EvalSymlinks the root (macOS /var → /private/var) + } + slashRoot := filepath.ToSlash(root) + return func(s string) string { + s = strings.ReplaceAll(s, `\\`, "/") // JSON-escaped Windows separators → '/' + return strings.ReplaceAll(s, slashRoot, "") + } +} + // TestGolden_MachineContract locks the byte-stable machine surfaces against the // committed fixture: a --json envelope, the csv/name shapes, or the schema contract // changing shape trips a diff. The in-process runRoot tests prove the *logic*; // these pin the exact bytes the agent contract promises. Run in-process (output is // identical to the binary's); main.go wiring is covered by the subprocess smoke. func TestGolden_MachineContract(t *testing.T) { + redact := redactFixtureRoot(t) cases := []struct { - name string - args []string + name string + args []string + redact func(string) string // optional: normalize machine-specific output }{ - {"task_list_json", []string{"-C", fixtureRepo, "task", "list", "--all", "--json"}}, - {"task_list_csv", []string{"-C", fixtureRepo, "task", "list", "--all", "-o", "csv"}}, - {"task_list_name", []string{"-C", fixtureRepo, "task", "list", "--all", "-o", "name"}}, - {"task_show_json", []string{"-C", fixtureRepo, "task", "show", "alpha-task", "--json"}}, - {"epic_list_json", []string{"-C", fixtureRepo, "epic", "list", "--json"}}, - {"epic_show_json", []string{"-C", fixtureRepo, "epic", "show", "01-fixture-epic", "--json"}}, - {"status_json", []string{"-C", fixtureRepo, "status", "--json"}}, - {"board_json", []string{"-C", fixtureRepo, "board", "--json"}}, - {"audit_findings_json", []string{"-C", fixtureRepo, "audit", "findings", "--json"}}, - {"audit_findings_open_json", []string{"-C", fixtureRepo, "audit", "findings", "--status", "open", "--json"}}, - {"lint_json", []string{"-C", fixtureRepo, "lint", "--json"}}, + {"task_list_json", []string{"-C", fixtureRepo, "task", "list", "--all", "--json"}, nil}, + {"task_list_csv", []string{"-C", fixtureRepo, "task", "list", "--all", "-o", "csv"}, nil}, + {"task_list_name", []string{"-C", fixtureRepo, "task", "list", "--all", "-o", "name"}, nil}, + {"task_show_json", []string{"-C", fixtureRepo, "task", "show", "alpha-task", "--json"}, nil}, + // task info / task path emit an absolute file path → redact the fixture root + // so the committed golden is portable (pins schema_version + shape + tally). + {"task_info_json", []string{"-C", fixtureRepo, "task", "info", "alpha-task", "--json"}, redact}, + {"task_path_json", []string{"-C", fixtureRepo, "task", "path", "alpha-task", "--json"}, redact}, + {"epic_path_json", []string{"-C", fixtureRepo, "epic", "path", "01-fixture-epic", "--json"}, redact}, + {"epic_list_json", []string{"-C", fixtureRepo, "epic", "list", "--json"}, nil}, + {"epic_show_json", []string{"-C", fixtureRepo, "epic", "show", "01-fixture-epic", "--json"}, nil}, + {"status_json", []string{"-C", fixtureRepo, "status", "--json"}, nil}, + {"board_json", []string{"-C", fixtureRepo, "board", "--json"}, nil}, + // audit info emits an absolute path → redact the fixture root (pins + // schema_version + bucket + finding tally shape). + {"audit_info_json", []string{"-C", fixtureRepo, "audit", "info", "2026-01-02-fixture-area", "--json"}, redact}, + {"audit_path_json", []string{"-C", fixtureRepo, "audit", "path", "2026-01-02-fixture-area", "--json"}, redact}, + {"audit_findings_json", []string{"-C", fixtureRepo, "audit", "findings", "--json"}, nil}, + {"audit_findings_open_json", []string{"-C", fixtureRepo, "audit", "findings", "--status", "open", "--json"}, nil}, + {"lint_json", []string{"-C", fixtureRepo, "lint", "--json"}, nil}, // Self-description runs anywhere (no planning repo needed) and is fully // date-free — ideal to pin byte-for-byte, especially the JSON Schema. - {"schema_json", []string{"schema", "--json"}}, - {"schema_task_json", []string{"schema", "task", "--json"}}, - {"schema_jsonschema", []string{"schema", "--json-schema"}}, + {"schema_json", []string{"schema", "--json"}, nil}, + {"schema_task_json", []string{"schema", "task", "--json"}, nil}, + {"schema_jsonschema", []string{"schema", "--json-schema"}, nil}, // Templates are built-in + date-free, so their --json is byte-pinnable too. - {"template_list_json", []string{"template", "list", "--json"}}, - {"template_show_security_json", []string{"template", "show", "audit", "security", "--json"}}, + {"template_list_json", []string{"template", "list", "--json"}, nil}, + {"template_show_security_json", []string{"template", "show", "audit", "security", "--json"}, nil}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - assertGolden(t, tc.name, runRoot(t, tc.args...)) + out := runRoot(t, tc.args...) + if tc.redact != nil { + out = tc.redact(out) + } + assertGolden(t, tc.name, out) }) } } diff --git a/internal/cli/render/info_test.go b/internal/cli/render/info_test.go new file mode 100644 index 0000000..7c1af22 --- /dev/null +++ b/internal/cli/render/info_test.go @@ -0,0 +1,32 @@ +package render + +import ( + "bytes" + "strings" + "testing" + + "github.com/andy-esch/taskflow/internal/domain" +) + +// The `info` reads must print the FULL path even on a narrow terminal — the path is +// meant to be copy-pasted, so truncating it (as the browse views do for long values) +// would defeat the command. Guards against a regression to fit=true in fieldPrinter. +func TestTaskInfoHuman_PathNotTruncated(t *testing.T) { + longPath := "/Users/someone/very/long/path/to/the/planning/tasks/6fxxxxxxxxxx-a-fairly-long-slug.md" + var b bytes.Buffer + st := NewStyle(false).WithWidth(40) // a narrow TTY + TaskInfoHuman(&b, st, domain.Task{Slug: "s", Status: domain.StatusReadyToStart}, domain.ACCount{Checked: 1, Total: 2}, longPath) + if !strings.Contains(b.String(), longPath) { + t.Errorf("task info must print the full path on a narrow terminal, got:\n%s", b.String()) + } +} + +func TestAuditInfoHuman_PathNotTruncated(t *testing.T) { + longPath := "/Users/someone/very/long/path/to/the/planning/audits/6fxxxxxxxxxx-2026-01-02-some-area.md" + var b bytes.Buffer + st := NewStyle(false).WithWidth(40) + AuditInfoHuman(&b, st, domain.Audit{Slug: "s", Bucket: domain.AuditOpen, Findings: 2, OpenFindings: 1}, longPath) + if !strings.Contains(b.String(), longPath) { + t.Errorf("audit info must print the full path on a narrow terminal, got:\n%s", b.String()) + } +} diff --git a/internal/cli/render/render.go b/internal/cli/render/render.go index fd214a7..ef0dff5 100644 --- a/internal/cli/render/render.go +++ b/internal/cli/render/render.go @@ -60,15 +60,25 @@ func TasksJSON(w io.Writer, tasks []domain.Task, problems []domain.FileProblem) return wire.EncodeJSON(w, wire.ToTasksEnvelope(tasks, problems)) } -// TaskShowHuman prints a task's metadata followed by its body. -func TaskShowHuman(w io.Writer, st Style, t domain.Task, body string) error { - field := func(label, value string) { - lbl := fmt.Sprintf("%-12s", label+":") - if st.width > 0 { // fit the value to the terminal (TTY only; piped stays full) +// fieldPrinter returns a key/value line writer for a metadata block: a dim, +// width-padded label then the value. When fit is true the value is trimmed to the +// terminal width on a TTY (the browse views — long descriptions shouldn't wrap); +// when false it prints in full — the `info` reads keep the whole value (their +// `path` is meant to be copy-pasted, so truncating it would defeat the command). +// Piped output is never trimmed either way. Shared so field rendering can't drift. +func fieldPrinter(w io.Writer, st Style, labelWidth int, fit bool) func(label, value string) { + return func(label, value string) { + lbl := fmt.Sprintf("%-*s", labelWidth, label+":") + if fit && st.width > 0 { value = truncate(value, st.width-visibleWidth(lbl)-1) } fmt.Fprintf(w, "%s %s\n", st.Dim(lbl), value) } +} + +// TaskShowHuman prints a task's metadata followed by its body. +func TaskShowHuman(w io.Writer, st Style, t domain.Task, body string) error { + field := fieldPrinter(w, st, 12, true) field("slug", st.Bold(t.Slug)) field("status", st.Status(t.Status)) if t.Epic != "" { @@ -92,7 +102,12 @@ func TaskShowHuman(w io.Writer, st Style, t domain.Task, body string) error { if t.Updated != "" { field("updated", fmt.Sprintf("%s %s", t.Updated, st.Dim("("+theme.RelativeDate(t.Updated)+")"))) } - fmt.Fprintf(w, "\n%s", body) + // Only emit the body block when there is one — a metadata-only view + // (`--frontmatter-only`, or a genuinely empty body) must not leave a trailing + // blank line under the fields. + if body != "" { + fmt.Fprintf(w, "\n%s", body) + } return nil } @@ -101,6 +116,48 @@ func TaskShowJSON(w io.Writer, t domain.Task, body string) error { return wire.EncodeJSON(w, wire.ToTaskShowEnvelope(t, body)) } +// TaskInfoJSON writes the token-cheap task metadata read (`task info --json`): +// path + triage fields + the acceptance-criteria tally, no body. +func TaskInfoJSON(w io.Writer, t domain.Task, ac domain.ACCount, path string) error { + return wire.EncodeJSON(w, wire.ToTaskInfoEnvelope(t, ac, path)) +} + +// TaskInfoHuman prints task metadata as an aligned key/value block (the human face +// of `task info`): where the file is, the fields an agent triages on, and the +// acceptance-criteria progress as "checked/total". +func TaskInfoHuman(w io.Writer, st Style, t domain.Task, ac domain.ACCount, path string) { + field := fieldPrinter(w, st, 9, false) + field("slug", st.Bold(t.Slug)) + field("status", st.Status(t.Status)) + if t.Epic != "" { + field("epic", t.Epic) + } + field("ac", fmt.Sprintf("%d/%d", ac.Checked, ac.Total)) + field("path", path) +} + +// AuditInfoJSON writes the token-cheap audit metadata read (`audit info --json`): +// path + bucket + finding tally, no body. +func AuditInfoJSON(w io.Writer, a domain.Audit, path string) error { + return wire.EncodeJSON(w, wire.ToAuditInfoEnvelope(a, path)) +} + +// AuditInfoHuman prints audit metadata as an aligned key/value block: bucket and +// the finding disposition tally (the audit analogue of a task's acceptance tally). +func AuditInfoHuman(w io.Writer, st Style, a domain.Audit, path string) { + field := fieldPrinter(w, st, 9, false) + field("slug", st.Bold(a.Slug)) + field("bucket", string(a.Bucket)) + field("findings", fmt.Sprintf("%d total · %d open · %d in-progress · %d done · %d dropped", + a.Findings, a.OpenFindings, a.ActiveFindings, a.DoneFindings, a.DroppedFindings)) + field("path", path) +} + +// PathJSON writes just the resolved absolute path (`task/epic/audit path --json`). +func PathJSON(w io.Writer, path string) error { + return wire.EncodeJSON(w, wire.ToPathEnvelope(path)) +} + // TaskMutationJSON writes the result of a task mutation (`task set`/`append`/`set // --body`): the reloaded task, dry_run (always present — a preview must be // distinguishable from a real write), and the resulting body for the body-editing @@ -472,7 +529,11 @@ func EpicShowHuman(w io.Writer, st Style, es core.EpicSummary, tasks []domain.Ta } fmt.Fprintln(w, tr) } - fmt.Fprintf(w, "\n%s", body) + // Skip the body block when empty (--frontmatter-only, or no body) so no trailing + // blank line is left under the roster/finding tree — as TaskShowHuman does. + if body != "" { + fmt.Fprintf(w, "\n%s", body) + } return nil } @@ -577,7 +638,11 @@ func AuditShowHuman(w io.Writer, st Style, a domain.Audit, findings []domain.Fin } fmt.Fprintln(w, tr) } - fmt.Fprintf(w, "\n%s", body) + // Skip the body block when empty (--frontmatter-only, or no body) so no trailing + // blank line is left under the roster/finding tree — as TaskShowHuman does. + if body != "" { + fmt.Fprintf(w, "\n%s", body) + } return nil } diff --git a/internal/cli/task.go b/internal/cli/task.go index 8514e38..c9e5c0a 100644 --- a/internal/cli/task.go +++ b/internal/cli/task.go @@ -4,6 +4,7 @@ import ( "fmt" "io" "os" + "path/filepath" "strings" "github.com/spf13/cobra" @@ -59,6 +60,8 @@ func newTaskCmd(app *App) *cobra.Command { newTaskNewCmd(app), newTaskListCmd(app), newTaskShowCmd(app), + newTaskInfoCmd(app), + newTaskPathCmd(app), newTaskSetCmd(app), newTaskEditCmd(app), newTaskAppendCmd(app), @@ -231,11 +234,15 @@ func completeStatusValues(*cobra.Command, []string, string) ([]string, cobra.She } func newTaskShowCmd(app *App) *cobra.Command { - var raw bool + var ( + raw bool + section string + fmOnly bool + ) cmd := &cobra.Command{ Use: "show ", Short: "Show a task's metadata and body", - Example: " tskflwctl task show add-retry-backoff\n tskflwctl task show # pick from a list", + Example: " tskflwctl task show add-retry-backoff\n tskflwctl task show add-retry-backoff --section acceptance\n tskflwctl task show add-retry-backoff --frontmatter-only", Args: cobra.MaximumNArgs(1), // bare → picker on a TTY; non-interactive needs the slug Annotations: map[string]string{"safety": "read-only"}, ValidArgsFunction: app.completeTaskSlugs, @@ -248,18 +255,111 @@ func newTaskShowCmd(app *App) *cobra.Command { if err != nil { return err } + // --section / --frontmatter-only narrow the body an agent has to read: + // one named section, or none at all. Both narrow the SAME body the full + // view emits, so the task metadata (and the --json envelope shape) are + // unchanged — only Body shrinks. + body, err = narrowBody("task", slug, body, section, fmOnly) + if err != nil { + return err + } if app.JSON { return render.TaskShowJSON(app.Out, task, body) } return app.paged(func(w io.Writer) error { + if fmOnly { // metadata block only — skip the (empty) body render entirely + return render.TaskShowHuman(w, app.Style, task, "") + } return render.TaskShowHuman(w, app.Style, task, render.RenderBody(app.Style, body, app.markdownStyle, raw)) }) }, } cmd.Flags().BoolVar(&raw, "raw", false, "print the raw markdown body (skip rendering)") + addBodyScopeFlags(cmd, §ion, &fmOnly) return cmd } +// newTaskInfoCmd is the token-cheap metadata read: where the file lives plus the +// triage fields and acceptance-criteria tally, WITHOUT the body `task show` +// carries. `--json` is the machine path (`{path,status,epic,ac:{checked,total}}`); +// the human face is a small aligned block. +func newTaskInfoCmd(app *App) *cobra.Command { + return &cobra.Command{ + Use: "info ", + Short: "Show a task's metadata + file path + acceptance tally (no body)", + Example: " tskflwctl task info add-retry-backoff\n tskflwctl task info add-retry-backoff --json", + Args: cobra.MaximumNArgs(1), + Annotations: map[string]string{"safety": "read-only"}, + ValidArgsFunction: app.completeTaskSlugs, + RunE: func(_ *cobra.Command, args []string) error { + slug, err := app.resolveOne(args, "specify a task", "no tasks available", "Task", app.taskOptions) + if err != nil { + return err + } + task, body, err := app.Svc.ShowTask(slug) + if err != nil { + return err + } + ac := domain.CountAcceptanceCriteria(body) + path := absPath(task.Path) + if app.JSON { + return render.TaskInfoJSON(app.Out, task, ac, path) + } + render.TaskInfoHuman(app.Out, app.Style, task, ac, path) + return nil + }, + } +} + +// newTaskPathCmd prints just the absolute path to a task's file — the minimal, +// pipe-friendly accessor (`$EDITOR "$(tskflwctl task path x)"`) that replaces +// globbing `find` on the id-led `-.md` filename. It resolves the path +// WITHOUT parsing (Svc.TaskPath), so it still works on a file with broken +// frontmatter — exactly when you need the path to go fix it. `--json` wraps it so +// the "schema_version everywhere" contract holds; plain prints the bare path. +func newTaskPathCmd(app *App) *cobra.Command { + return &cobra.Command{ + Use: "path ", + Short: "Print the absolute path to a task's file", + Example: " tskflwctl task path add-retry-backoff\n $EDITOR \"$(tskflwctl task path add-retry-backoff)\"", + Args: cobra.MaximumNArgs(1), + Annotations: map[string]string{"safety": "read-only"}, + ValidArgsFunction: app.completeTaskSlugs, + RunE: func(_ *cobra.Command, args []string) error { + slug, err := app.resolveOne(args, "specify a task", "no tasks available", "Task", app.taskOptions) + if err != nil { + return err + } + p, err := app.Svc.TaskPath(slug) + if err != nil { + return err + } + return emitPath(app, absPath(p)) + }, + } +} + +// emitPath writes a resolved file path: the `path` --json envelope, or the bare +// path for piping. Shared by task/epic/audit path. +func emitPath(app *App, path string) error { + if app.JSON { + return render.PathJSON(app.Out, path) + } + fmt.Fprintln(app.Out, path) + return nil +} + +// absPath makes a store path absolute so `task path`/`task info` emit a path that +// resolves from anywhere, regardless of how the planning root was configured +// (relative config root, -C, etc.). A failure to absolutize (never expected for a +// real file) falls back to the store path rather than erroring a read. +func absPath(p string) string { + if abs, err := filepath.Abs(p); err == nil { + return abs + } + return p +} + func newTaskSetCmd(app *App) *cobra.Command { var ( description, priority, epic, effort string diff --git a/internal/cli/taskinfo_test.go b/internal/cli/taskinfo_test.go new file mode 100644 index 0000000..2fb4a4d --- /dev/null +++ b/internal/cli/taskinfo_test.go @@ -0,0 +1,156 @@ +package cli + +import ( + "encoding/json" + "errors" + "path/filepath" + "strings" + "testing" + + "github.com/andy-esch/taskflow/internal/domain" +) + +// wantAlphaPath is alpha's canonical absolute path — resolving symlinks the way +// discovery does (macOS /var → /private/var), so it matches the store-emitted path. +func wantAlphaPath(t *testing.T, root string) string { + t.Helper() + p, err := filepath.EvalSymlinks(alphaPath(root)) + if err != nil { + t.Fatal(err) + } + return p +} + +// `task path` prints exactly the absolute file path (pipe-friendly, no globbing). +func TestTaskPath(t *testing.T) { + root := setupRepo(t) + out := runRoot(t, "-C", root, "task", "path", "alpha") + want := wantAlphaPath(t, root) + if strings.TrimSpace(out) != want { + t.Errorf("task path = %q, want %q", strings.TrimSpace(out), want) + } + if strings.Contains(strings.TrimSpace(out), "\n") { + t.Errorf("task path should print exactly one line:\n%s", out) + } +} + +// `task path --json` wraps the same path so the schema_version contract holds. +func TestTaskPath_JSON(t *testing.T) { + root := setupRepo(t) + out := runRoot(t, "-C", root, "--json", "task", "path", "alpha") + var env struct { + SchemaVersion string `json:"schema_version"` + Path string `json:"path"` + } + if err := json.Unmarshal([]byte(out), &env); err != nil { + t.Fatalf("task path --json not parseable: %v\n%s", err, out) + } + want := wantAlphaPath(t, root) + if env.SchemaVersion == "" || env.Path != want { + t.Errorf("task path --json wrong: %+v, want path %q", env, want) + } +} + +// `task info --json` is the token-cheap metadata read: path + triage fields + the +// acceptance-criteria tally, no body. +func TestTaskInfo_JSON(t *testing.T) { + root := setupRepo(t) + // Give alpha an acceptance-criteria section: 1 of 2 checked. + runRoot(t, "-C", root, "task", "set", "alpha", "--body", + "# Alpha\n\n## Acceptance criteria\n\n- [x] done\n- [ ] not yet\n") + out := runRoot(t, "-C", root, "--json", "task", "info", "alpha") + var env struct { + SchemaVersion string `json:"schema_version"` + TaskInfo struct { + Slug string `json:"slug"` + Status string `json:"status"` + Path string `json:"path"` + AC struct { + Checked int `json:"checked"` + Total int `json:"total"` + } `json:"ac"` + } `json:"task_info"` + } + if err := json.Unmarshal([]byte(out), &env); err != nil { + t.Fatalf("task info --json not parseable: %v\n%s", err, out) + } + want := wantAlphaPath(t, root) + ti := env.TaskInfo + if env.SchemaVersion == "" || ti.Slug != "alpha" || ti.Status != "ready-to-start" || ti.Path != want { + t.Errorf("task info --json metadata wrong:\n%s", out) + } + if ti.AC.Checked != 1 || ti.AC.Total != 2 { + t.Errorf("ac tally = %d/%d, want 1/2\n%s", ti.AC.Checked, ti.AC.Total, out) + } +} + +// A task with no acceptance-criteria section reports a zero tally, not an error. +func TestTaskInfo_NoAcceptanceSection(t *testing.T) { + root := setupRepo(t) // alpha's fixture body is just "# Alpha" + out := runRoot(t, "-C", root, "--json", "task", "info", "alpha") + if !strings.Contains(out, `"ac":{"checked":0,"total":0}`) { + t.Errorf("no AC section should be a 0/0 tally:\n%s", out) + } +} + +// `task show --section` narrows the human output to one section. +func TestTaskShow_Section(t *testing.T) { + root := setupRepo(t) + runRoot(t, "-C", root, "task", "set", "alpha", "--body", + "# Alpha\n\n## Objective\n\nDo the thing.\n\n## Acceptance criteria\n\n- [ ] a\n- [ ] b\n") + out := runRoot(t, "-C", root, "task", "show", "alpha", "--section", "acceptance", "--raw") + if !strings.Contains(out, "## Acceptance criteria") || !strings.Contains(out, "- [ ] a") { + t.Errorf("--section acceptance should show that section:\n%s", out) + } + if strings.Contains(out, "## Objective") || strings.Contains(out, "Do the thing.") { + t.Errorf("--section acceptance must NOT show other sections:\n%s", out) + } +} + +// Under --json, --section narrows the body field (envelope shape unchanged). +func TestTaskShow_SectionJSON_NarrowsBody(t *testing.T) { + root := setupRepo(t) + runRoot(t, "-C", root, "task", "set", "alpha", "--body", + "# Alpha\n\n## Objective\n\ntop\n\n## Acceptance criteria\n\n- [ ] a\n") + out := runRoot(t, "-C", root, "--json", "task", "show", "alpha", "--section", "acceptance") + var env struct { + Body string `json:"body"` + } + if err := json.Unmarshal([]byte(out), &env); err != nil { + t.Fatalf("task show --section --json not parseable: %v\n%s", err, out) + } + if !strings.HasPrefix(env.Body, "## Acceptance criteria") || strings.Contains(env.Body, "Objective") { + t.Errorf("--json --section should narrow body to the section:\n%q", env.Body) + } +} + +// A missing section is a clean not-found (exit 10), not a silent empty body. +func TestTaskShow_SectionNotFound(t *testing.T) { + root := setupRepo(t) + if _, err := runRootRC(t, "-C", root, "task", "show", "alpha", "--section", "nonexistent"); !errors.Is(err, domain.ErrNotFound) { + t.Errorf("a missing section should wrap ErrNotFound (exit 10), got %v", err) + } +} + +// `task show --frontmatter-only` keeps the metadata, drops the body. +func TestTaskShow_FrontmatterOnly(t *testing.T) { + root := setupRepo(t) + out := runRoot(t, "-C", root, "task", "show", "alpha", "--frontmatter-only") + if !strings.Contains(out, "alpha") { + t.Errorf("--frontmatter-only should still show metadata:\n%s", out) + } + if strings.Contains(out, "# Alpha") { + t.Errorf("--frontmatter-only must omit the body:\n%s", out) + } + // No body → no trailing blank line under the metadata fields. + if strings.HasSuffix(out, "\n\n") { + t.Errorf("--frontmatter-only must not leave a trailing blank line:\n%q", out) + } +} + +func TestTaskShow_SectionAndFrontmatterOnly_Exclusive(t *testing.T) { + root := setupRepo(t) + if _, err := runRootRC(t, "-C", root, "task", "show", "alpha", "--section", "x", "--frontmatter-only"); err == nil { + t.Fatal("--section and --frontmatter-only should be mutually exclusive") + } +} diff --git a/internal/cli/testdata/golden/audit_findings_json.golden b/internal/cli/testdata/golden/audit_findings_json.golden index 7b5395c..8afbb70 100644 --- a/internal/cli/testdata/golden/audit_findings_json.golden +++ b/internal/cli/testdata/golden/audit_findings_json.golden @@ -1 +1 @@ -{"schema_version":"1.26","findings":[{"audit":"2026-01-02-fixture-area","bucket":"open","code":"S1","title":"Tighten the fixture gateway","status":"open","component":"fixturepipe","effort":"S","urgency":"soon"},{"audit":"2026-01-02-fixture-area","bucket":"open","code":"H1","title":"Fix the fixture bypass","status":"fixed","component":"auth","effort":"M","urgency":"acute"}]} +{"schema_version":"1.28","findings":[{"audit":"2026-01-02-fixture-area","bucket":"open","code":"S1","title":"Tighten the fixture gateway","status":"open","component":"fixturepipe","effort":"S","urgency":"soon"},{"audit":"2026-01-02-fixture-area","bucket":"open","code":"H1","title":"Fix the fixture bypass","status":"fixed","component":"auth","effort":"M","urgency":"acute"}]} diff --git a/internal/cli/testdata/golden/audit_findings_open_json.golden b/internal/cli/testdata/golden/audit_findings_open_json.golden index 25f8103..0bd8a2e 100644 --- a/internal/cli/testdata/golden/audit_findings_open_json.golden +++ b/internal/cli/testdata/golden/audit_findings_open_json.golden @@ -1 +1 @@ -{"schema_version":"1.26","findings":[{"audit":"2026-01-02-fixture-area","bucket":"open","code":"S1","title":"Tighten the fixture gateway","status":"open","component":"fixturepipe","effort":"S","urgency":"soon"}]} +{"schema_version":"1.28","findings":[{"audit":"2026-01-02-fixture-area","bucket":"open","code":"S1","title":"Tighten the fixture gateway","status":"open","component":"fixturepipe","effort":"S","urgency":"soon"}]} diff --git a/internal/cli/testdata/golden/audit_info_json.golden b/internal/cli/testdata/golden/audit_info_json.golden new file mode 100644 index 0000000..3a15426 --- /dev/null +++ b/internal/cli/testdata/golden/audit_info_json.golden @@ -0,0 +1 @@ +{"schema_version":"1.28","audit_info":{"id":"6fjangd7kvh3","slug":"2026-01-02-fixture-area","bucket":"open","path":"/audits/6fjangd7kvh3-2026-01-02-fixture-area.md","findings":{"total":2,"open":1,"in_progress":0,"done":1,"dropped":0}}} diff --git a/internal/cli/testdata/golden/audit_path_json.golden b/internal/cli/testdata/golden/audit_path_json.golden new file mode 100644 index 0000000..62265a2 --- /dev/null +++ b/internal/cli/testdata/golden/audit_path_json.golden @@ -0,0 +1 @@ +{"schema_version":"1.28","path":"/audits/6fjangd7kvh3-2026-01-02-fixture-area.md"} diff --git a/internal/cli/testdata/golden/board_json.golden b/internal/cli/testdata/golden/board_json.golden index e6ce877..6c725e3 100644 --- a/internal/cli/testdata/golden/board_json.golden +++ b/internal/cli/testdata/golden/board_json.golden @@ -1 +1 @@ -{"schema_version":"1.26","columns":[{"status":"next-up","tasks":[]},{"status":"ready-to-start","tasks":[{"id":"6fjangd7kvh0","slug":"alpha-task","status":"ready-to-start","epic":"01-fixture-epic","description":"A fully specified ready-to-start task for golden snapshots","effort":"S","tier":2,"priority":"high","autonomy_level":3,"created":"2026-01-02","updated_at":"2026-01-03","tags":["cli","testing"]}]},{"status":"in-progress","tasks":[{"id":"6fjangd7kvh1","slug":"beta-task","status":"in-progress","epic":"01-fixture-epic","description":"An in-progress fixture task","effort":"M","tier":3,"priority":"medium","autonomy_level":2,"created":"2026-01-02","updated_at":"2026-01-03","tags":["cli"]}]}]} +{"schema_version":"1.28","columns":[{"status":"next-up","tasks":[]},{"status":"ready-to-start","tasks":[{"id":"6fjangd7kvh0","slug":"alpha-task","status":"ready-to-start","epic":"01-fixture-epic","description":"A fully specified ready-to-start task for golden snapshots","effort":"S","tier":2,"priority":"high","autonomy_level":3,"created":"2026-01-02","updated_at":"2026-01-03","tags":["cli","testing"]}]},{"status":"in-progress","tasks":[{"id":"6fjangd7kvh1","slug":"beta-task","status":"in-progress","epic":"01-fixture-epic","description":"An in-progress fixture task","effort":"M","tier":3,"priority":"medium","autonomy_level":2,"created":"2026-01-02","updated_at":"2026-01-03","tags":["cli"]}]}]} diff --git a/internal/cli/testdata/golden/epic_list_json.golden b/internal/cli/testdata/golden/epic_list_json.golden index 8c8ca0d..dbdc2d9 100644 --- a/internal/cli/testdata/golden/epic_list_json.golden +++ b/internal/cli/testdata/golden/epic_list_json.golden @@ -1 +1 @@ -{"schema_version":"1.26","epics":[{"id":"01-fixture-epic","status":"active","description":"A fixture epic for golden snapshots","priority":"high","created":"2026-01-01","tags":["fixture"],"total":3,"done":1,"open":2,"percent":33,"deprecated":0,"liveness":"working"}]} +{"schema_version":"1.28","epics":[{"id":"01-fixture-epic","status":"active","description":"A fixture epic for golden snapshots","priority":"high","created":"2026-01-01","tags":["fixture"],"total":3,"done":1,"open":2,"percent":33,"deprecated":0,"liveness":"working"}]} diff --git a/internal/cli/testdata/golden/epic_path_json.golden b/internal/cli/testdata/golden/epic_path_json.golden new file mode 100644 index 0000000..a02a012 --- /dev/null +++ b/internal/cli/testdata/golden/epic_path_json.golden @@ -0,0 +1 @@ +{"schema_version":"1.28","path":"/epics/01-fixture-epic.md"} diff --git a/internal/cli/testdata/golden/epic_show_json.golden b/internal/cli/testdata/golden/epic_show_json.golden index 447572e..0dda97f 100644 --- a/internal/cli/testdata/golden/epic_show_json.golden +++ b/internal/cli/testdata/golden/epic_show_json.golden @@ -1 +1 @@ -{"schema_version":"1.26","epic":{"id":"01-fixture-epic","status":"active","description":"A fixture epic for golden snapshots","priority":"high","created":"2026-01-01","tags":["fixture"]},"tasks":[{"id":"6fjangd7kvh0","slug":"alpha-task","status":"ready-to-start","epic":"01-fixture-epic","description":"A fully specified ready-to-start task for golden snapshots","effort":"S","tier":2,"priority":"high","autonomy_level":3,"created":"2026-01-02","updated_at":"2026-01-03","tags":["cli","testing"]},{"id":"6fjangd7kvh1","slug":"beta-task","status":"in-progress","epic":"01-fixture-epic","description":"An in-progress fixture task","effort":"M","tier":3,"priority":"medium","autonomy_level":2,"created":"2026-01-02","updated_at":"2026-01-03","tags":["cli"]},{"id":"6fjangd7kvh2","slug":"gamma-task","status":"completed","epic":"01-fixture-epic","description":"A completed fixture task","tier":1,"priority":"low","created":"2026-01-01","updated_at":"2026-01-02","tags":["docs"]}],"body":"# Fixture Epic\n\nThe epic that the fixture tasks roll up into.\n"} +{"schema_version":"1.28","epic":{"id":"01-fixture-epic","status":"active","description":"A fixture epic for golden snapshots","priority":"high","created":"2026-01-01","tags":["fixture"]},"tasks":[{"id":"6fjangd7kvh0","slug":"alpha-task","status":"ready-to-start","epic":"01-fixture-epic","description":"A fully specified ready-to-start task for golden snapshots","effort":"S","tier":2,"priority":"high","autonomy_level":3,"created":"2026-01-02","updated_at":"2026-01-03","tags":["cli","testing"]},{"id":"6fjangd7kvh1","slug":"beta-task","status":"in-progress","epic":"01-fixture-epic","description":"An in-progress fixture task","effort":"M","tier":3,"priority":"medium","autonomy_level":2,"created":"2026-01-02","updated_at":"2026-01-03","tags":["cli"]},{"id":"6fjangd7kvh2","slug":"gamma-task","status":"completed","epic":"01-fixture-epic","description":"A completed fixture task","tier":1,"priority":"low","created":"2026-01-01","updated_at":"2026-01-02","tags":["docs"]}],"body":"# Fixture Epic\n\nThe epic that the fixture tasks roll up into.\n"} diff --git a/internal/cli/testdata/golden/lint_json.golden b/internal/cli/testdata/golden/lint_json.golden index 3cfc1e5..c5c7aae 100644 --- a/internal/cli/testdata/golden/lint_json.golden +++ b/internal/cli/testdata/golden/lint_json.golden @@ -1 +1 @@ -{"schema_version":"1.26","unreadable":[],"issues":[]} +{"schema_version":"1.28","unreadable":[],"issues":[]} diff --git a/internal/cli/testdata/golden/schema_json.golden b/internal/cli/testdata/golden/schema_json.golden index 5136c45..0bbb393 100644 --- a/internal/cli/testdata/golden/schema_json.golden +++ b/internal/cli/testdata/golden/schema_json.golden @@ -1 +1 @@ -{"schema_version":"1.26","statuses":[{"value":"next-up","active":true},{"value":"ready-to-start","active":true},{"value":"in-progress","active":true},{"value":"completed","active":false},{"value":"deprecated","active":false},{"value":"deferred","active":false}],"epic_statuses":["active","retired","deprecated"],"audit_buckets":["open","closed","deferred"],"finding_statuses":["deferred","fixed","in-progress","landed","open","superseded","wontfix"],"task_fields":[{"name":"audit_sources","type":"list"},{"name":"audited","type":"date"},{"name":"autonomy_level","type":"int"},{"name":"blocked_by","type":"list"},{"name":"blocks","type":"list"},{"name":"completed_at","type":"date"},{"name":"created","type":"date"},{"name":"deferred_at","type":"date"},{"name":"dependencies","type":"list"},{"name":"deprecated_at","type":"date"},{"name":"description","type":"string"},{"name":"effort","type":"string"},{"name":"epic","type":"string"},{"name":"priority","type":"string"},{"name":"projects","type":"list"},{"name":"related_tasks","type":"list"},{"name":"revisit_at","type":"date"},{"name":"started_at","type":"date"},{"name":"status","type":"string"},{"name":"tags","type":"list"},{"name":"tier","type":"int"},{"name":"updated_at","type":"date"}],"epic_fields":["created","description","priority","status","tags"],"exit_codes":[{"code":10,"name":"not-found"},{"code":11,"name":"validation"},{"code":13,"name":"ambiguous"},{"code":14,"name":"conflict"}],"kinds":["task","epic","audit"]} +{"schema_version":"1.28","statuses":[{"value":"next-up","active":true},{"value":"ready-to-start","active":true},{"value":"in-progress","active":true},{"value":"completed","active":false},{"value":"deprecated","active":false},{"value":"deferred","active":false}],"epic_statuses":["active","retired","deprecated"],"audit_buckets":["open","closed","deferred"],"finding_statuses":["deferred","fixed","in-progress","landed","open","superseded","wontfix"],"task_fields":[{"name":"audit_sources","type":"list"},{"name":"audited","type":"date"},{"name":"autonomy_level","type":"int"},{"name":"blocked_by","type":"list"},{"name":"blocks","type":"list"},{"name":"completed_at","type":"date"},{"name":"created","type":"date"},{"name":"deferred_at","type":"date"},{"name":"dependencies","type":"list"},{"name":"deprecated_at","type":"date"},{"name":"description","type":"string"},{"name":"effort","type":"string"},{"name":"epic","type":"string"},{"name":"priority","type":"string"},{"name":"projects","type":"list"},{"name":"related_tasks","type":"list"},{"name":"revisit_at","type":"date"},{"name":"started_at","type":"date"},{"name":"status","type":"string"},{"name":"tags","type":"list"},{"name":"tier","type":"int"},{"name":"updated_at","type":"date"}],"epic_fields":["created","description","priority","status","tags"],"exit_codes":[{"code":10,"name":"not-found"},{"code":11,"name":"validation"},{"code":13,"name":"ambiguous"},{"code":14,"name":"conflict"}],"kinds":["task","epic","audit"]} diff --git a/internal/cli/testdata/golden/schema_jsonschema.golden b/internal/cli/testdata/golden/schema_jsonschema.golden index 13abfbc..35c73ac 100644 --- a/internal/cli/testdata/golden/schema_jsonschema.golden +++ b/internal/cli/testdata/golden/schema_jsonschema.golden @@ -3,6 +3,75 @@ "$id": "https://github.com/andy-esch/taskflow/internal/wire/json-envelopes", "$ref": "#/$defs/jsonEnvelopes", "$defs": { + "ACJSON": { + "properties": { + "checked": { + "type": "integer", + "description": "acceptance criteria currently checked (- [x])" + }, + "total": { + "type": "integer", + "description": "total acceptance criteria in the task's acceptance-criteria section" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "checked", + "total" + ], + "description": "ACJSON is a task's acceptance-criteria checkbox tally (the `ac` field of `task info`): how many criteria are checked out of the total." + }, + "AuditInfoEnvelope": { + "properties": { + "schema_version": { + "type": "string" + }, + "audit_info": { + "$ref": "#/$defs/AuditInfoJSON" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "schema_version", + "audit_info" + ], + "description": "AuditInfoEnvelope wraps `audit info --json` — the token-cheap audit metadata read (path + bucket + finding tally, no body), the audit counterpart to TaskInfoEnvelope." + }, + "AuditInfoJSON": { + "properties": { + "id": { + "type": "string", + "description": "stable identifier — absent on audits created before id assignment" + }, + "slug": { + "type": "string", + "description": "audit slug (filename without .md)" + }, + "bucket": { + "type": "string", + "description": "open | closed | deferred — authoritative from frontmatter (ADR-0003 §4)" + }, + "path": { + "type": "string", + "description": "absolute path to the audit's markdown file" + }, + "findings": { + "$ref": "#/$defs/FindingsTallyJSON", + "description": "finding disposition tally" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "slug", + "bucket", + "path", + "findings" + ], + "description": "AuditInfoJSON is the token-cheap metadata read for an audit (`audit info`): where the file lives, its bucket, and the finding tally — no body." + }, "AuditJSON": { "properties": { "id": { @@ -670,6 +739,40 @@ ], "description": "FindingsRollupJSON aggregates the actionable audit findings (status open or in-progress) across all audits — the `status` summary's \"audit findings\" view." }, + "FindingsTallyJSON": { + "properties": { + "total": { + "type": "integer", + "description": "total findings parsed from the audit body" + }, + "open": { + "type": "integer", + "description": "findings whose status is open" + }, + "in_progress": { + "type": "integer", + "description": "findings whose status is in-progress" + }, + "done": { + "type": "integer", + "description": "findings whose status is fixed or landed" + }, + "dropped": { + "type": "integer", + "description": "findings whose status is deferred" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "total", + "open", + "in_progress", + "done", + "dropped" + ], + "description": "FindingsTallyJSON is an audit's finding disposition tally (the `findings` field of `audit info`) — the audit analogue of a task's acceptance-criteria tally." + }, "FixEnvelope": { "properties": { "schema_version": { @@ -884,6 +987,24 @@ ], "description": "MovesEnvelope is the transition report (`task start --json`, etc.)." }, + "PathEnvelope": { + "properties": { + "schema_version": { + "type": "string" + }, + "path": { + "type": "string", + "description": "absolute path to the entity's markdown file" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "schema_version", + "path" + ], + "description": "PathEnvelope wraps `task path --json` — just the resolved absolute file path (the plain form prints the bare path for piping, e.g." + }, "SchemaEnvelope": { "properties": { "schema_version": { @@ -1127,6 +1248,60 @@ ], "description": "SummaryEnvelope is `status --json`." }, + "TaskInfoEnvelope": { + "properties": { + "schema_version": { + "type": "string" + }, + "task_info": { + "$ref": "#/$defs/TaskInfoJSON" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "schema_version", + "task_info" + ], + "description": "TaskInfoEnvelope wraps `task info --json` — the token-cheap task metadata read (file path + triage fields + acceptance tally, no body)." + }, + "TaskInfoJSON": { + "properties": { + "id": { + "type": "string", + "description": "stable identifier — absent on tasks created before id assignment" + }, + "slug": { + "type": "string", + "description": "task slug (filename without .md)" + }, + "status": { + "type": "string", + "description": "lifecycle status — authoritative from frontmatter (ADR-0003 §4)" + }, + "epic": { + "type": "string", + "description": "id of the epic this task belongs to" + }, + "path": { + "type": "string", + "description": "absolute path to the task's markdown file" + }, + "ac": { + "$ref": "#/$defs/ACJSON", + "description": "acceptance-criteria checkbox tally" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "slug", + "status", + "path", + "ac" + ], + "description": "TaskInfoJSON is the token-cheap metadata read for a task (`task info`): where the file lives plus the fields an agent triages on and the acceptance-criteria tally, WITHOUT the body — the machine counterpart to `task path` that avoids the full `task show` payload." + }, "TaskJSON": { "properties": { "id": { @@ -1450,6 +1625,12 @@ "task_show": { "$ref": "#/$defs/TaskShowEnvelope" }, + "task_info": { + "$ref": "#/$defs/TaskInfoEnvelope" + }, + "path": { + "$ref": "#/$defs/PathEnvelope" + }, "task_mutation": { "$ref": "#/$defs/TaskMutationEnvelope" }, @@ -1480,6 +1661,9 @@ "audit_show": { "$ref": "#/$defs/AuditShowEnvelope" }, + "audit_info": { + "$ref": "#/$defs/AuditInfoEnvelope" + }, "audit_mutation": { "$ref": "#/$defs/AuditMutationEnvelope" }, @@ -1526,6 +1710,8 @@ "tasks", "board", "task_show", + "task_info", + "path", "task_mutation", "epic_mutation", "moves", @@ -1536,6 +1722,7 @@ "epic_show", "audits", "audit_show", + "audit_info", "audit_mutation", "findings", "fix", @@ -1552,6 +1739,6 @@ ] } }, - "title": "tskflwctl --json output (schema_version 1.26)", + "title": "tskflwctl --json output (schema_version 1.28)", "description": "Each property of the root names a --json envelope and references its definition in $defs; validate a command's --json output against the matching definition." } diff --git a/internal/cli/testdata/golden/schema_task_json.golden b/internal/cli/testdata/golden/schema_task_json.golden index b08b291..4cf78b0 100644 --- a/internal/cli/testdata/golden/schema_task_json.golden +++ b/internal/cli/testdata/golden/schema_task_json.golden @@ -1 +1 @@ -{"schema_version":"1.26","kind":"task","sections":["Objective","Acceptance criteria","Out of scope","Related"],"body_template":"\n# \u003ctitle\u003e\n\n## Objective\n\n\u003cwhy / what — one short paragraph\u003e\n\n## Acceptance criteria\n\n- [ ] \u003cobservable outcome\u003e\n\n## Out of scope\n\n- \u003cexplicitly excluded\u003e\n\n## Related\n\n- Epic [\u003cepic-id\u003e](../epics/\u003cepic-id\u003e.md)\n","fields":[{"name":"epic","type":"string","required":true,"description":"ID of the epic this task belongs to; must already exist.","example":"17-pm-go-cli"},{"name":"description","type":"string","required":false,"description":"One line summarizing the task (≤200 chars); required once next-up/in-progress.","example":"Add retry backoff to the Strava webhook"},{"name":"effort","type":"string","required":false,"description":"Rough size estimate (free-form).","example":"1-2 hours"},{"name":"tier","type":"int","required":false,"description":"Importance, 1 (highest) – 5 (lowest).","example":"2"},{"name":"priority","type":"string","required":false,"description":"One of: high | medium | low.","example":"medium"},{"name":"autonomy_level","type":"int","required":false,"description":"How autonomously this can be done, 1–5.","example":"3"},{"name":"tags","type":"list","required":true,"description":"At least one topical tag (required at creation).","example":"[cli, core]"}],"conventions":["status lives in frontmatter (authoritative) and is changed only via the lifecycle verbs (start/next/complete/…), which edit it in place — don't edit it directly.","description is a single line, ≤200 characters.","at least one tag is required at creation.","the filename slug is derived from the title; any title is accepted (colons, dashes, arrows, …) and the full title is kept as the body H1."],"templates":[{"kind":"task","name":"default","description":"Standard task scaffold: objective, acceptance criteria, out-of-scope, related epic."}]} +{"schema_version":"1.28","kind":"task","sections":["Objective","Acceptance criteria","Out of scope","Related"],"body_template":"\n# \u003ctitle\u003e\n\n## Objective\n\n\u003cwhy / what — one short paragraph\u003e\n\n## Acceptance criteria\n\n- [ ] \u003cobservable outcome\u003e\n\n## Out of scope\n\n- \u003cexplicitly excluded\u003e\n\n## Related\n\n- Epic [\u003cepic-id\u003e](../epics/\u003cepic-id\u003e.md)\n","fields":[{"name":"epic","type":"string","required":true,"description":"ID of the epic this task belongs to; must already exist.","example":"17-pm-go-cli"},{"name":"description","type":"string","required":false,"description":"One line summarizing the task (≤200 chars); required once next-up/in-progress.","example":"Add retry backoff to the Strava webhook"},{"name":"effort","type":"string","required":false,"description":"Rough size estimate (free-form).","example":"1-2 hours"},{"name":"tier","type":"int","required":false,"description":"Importance, 1 (highest) – 5 (lowest).","example":"2"},{"name":"priority","type":"string","required":false,"description":"One of: high | medium | low.","example":"medium"},{"name":"autonomy_level","type":"int","required":false,"description":"How autonomously this can be done, 1–5.","example":"3"},{"name":"tags","type":"list","required":true,"description":"At least one topical tag (required at creation).","example":"[cli, core]"}],"conventions":["status lives in frontmatter (authoritative) and is changed only via the lifecycle verbs (start/next/complete/…), which edit it in place — don't edit it directly.","description is a single line, ≤200 characters.","at least one tag is required at creation.","the filename slug is derived from the title; any title is accepted (colons, dashes, arrows, …) and the full title is kept as the body H1."],"templates":[{"kind":"task","name":"default","description":"Standard task scaffold: objective, acceptance criteria, out-of-scope, related epic."}]} diff --git a/internal/cli/testdata/golden/status_json.golden b/internal/cli/testdata/golden/status_json.golden index eda1365..0515cc9 100644 --- a/internal/cli/testdata/golden/status_json.golden +++ b/internal/cli/testdata/golden/status_json.golden @@ -1 +1 @@ -{"schema_version":"1.26","counts":[{"status":"next-up","count":0},{"status":"ready-to-start","count":1},{"status":"in-progress","count":1},{"status":"completed","count":1},{"status":"deprecated","count":0},{"status":"deferred","count":0}],"in_progress":[{"id":"6fjangd7kvh1","slug":"beta-task","status":"in-progress","epic":"01-fixture-epic","description":"An in-progress fixture task","effort":"M","tier":3,"priority":"medium","autonomy_level":2,"created":"2026-01-02","updated_at":"2026-01-03","tags":["cli"]}],"epics":[{"id":"01-fixture-epic","status":"active","description":"A fixture epic for golden snapshots","priority":"high","created":"2026-01-01","tags":["fixture"],"total":3,"done":1,"open":2,"percent":33,"deprecated":0,"liveness":"working"}],"open_audits":[{"id":"6fjangd7kvh3","slug":"2026-01-02-fixture-area","bucket":"open","area":"fixture-area","date":"2026-01-02","findings":2,"open_findings":1,"in_progress_findings":0,"done_findings":1,"dropped_findings":0}],"findings":{"open":1,"in_progress":0,"by_urgency":[{"key":"soon","count":1}],"by_component":[{"key":"fixturepipe","count":1}]},"revisit_due":0,"bad_epic_status":0} +{"schema_version":"1.28","counts":[{"status":"next-up","count":0},{"status":"ready-to-start","count":1},{"status":"in-progress","count":1},{"status":"completed","count":1},{"status":"deprecated","count":0},{"status":"deferred","count":0}],"in_progress":[{"id":"6fjangd7kvh1","slug":"beta-task","status":"in-progress","epic":"01-fixture-epic","description":"An in-progress fixture task","effort":"M","tier":3,"priority":"medium","autonomy_level":2,"created":"2026-01-02","updated_at":"2026-01-03","tags":["cli"]}],"epics":[{"id":"01-fixture-epic","status":"active","description":"A fixture epic for golden snapshots","priority":"high","created":"2026-01-01","tags":["fixture"],"total":3,"done":1,"open":2,"percent":33,"deprecated":0,"liveness":"working"}],"open_audits":[{"id":"6fjangd7kvh3","slug":"2026-01-02-fixture-area","bucket":"open","area":"fixture-area","date":"2026-01-02","findings":2,"open_findings":1,"in_progress_findings":0,"done_findings":1,"dropped_findings":0}],"findings":{"open":1,"in_progress":0,"by_urgency":[{"key":"soon","count":1}],"by_component":[{"key":"fixturepipe","count":1}]},"revisit_due":0,"bad_epic_status":0} diff --git a/internal/cli/testdata/golden/task_info_json.golden b/internal/cli/testdata/golden/task_info_json.golden new file mode 100644 index 0000000..c9c420c --- /dev/null +++ b/internal/cli/testdata/golden/task_info_json.golden @@ -0,0 +1 @@ +{"schema_version":"1.28","task_info":{"id":"6fjangd7kvh0","slug":"alpha-task","status":"ready-to-start","epic":"01-fixture-epic","path":"/tasks/6fjangd7kvh0-alpha-task.md","ac":{"checked":0,"total":0}}} diff --git a/internal/cli/testdata/golden/task_list_json.golden b/internal/cli/testdata/golden/task_list_json.golden index 9d8d058..cb775bf 100644 --- a/internal/cli/testdata/golden/task_list_json.golden +++ b/internal/cli/testdata/golden/task_list_json.golden @@ -1 +1 @@ -{"schema_version":"1.26","tasks":[{"id":"6fjangd7kvh0","slug":"alpha-task","status":"ready-to-start","epic":"01-fixture-epic","description":"A fully specified ready-to-start task for golden snapshots","effort":"S","tier":2,"priority":"high","autonomy_level":3,"created":"2026-01-02","updated_at":"2026-01-03","tags":["cli","testing"]},{"id":"6fjangd7kvh1","slug":"beta-task","status":"in-progress","epic":"01-fixture-epic","description":"An in-progress fixture task","effort":"M","tier":3,"priority":"medium","autonomy_level":2,"created":"2026-01-02","updated_at":"2026-01-03","tags":["cli"]},{"id":"6fjangd7kvh2","slug":"gamma-task","status":"completed","epic":"01-fixture-epic","description":"A completed fixture task","tier":1,"priority":"low","created":"2026-01-01","updated_at":"2026-01-02","tags":["docs"]}]} +{"schema_version":"1.28","tasks":[{"id":"6fjangd7kvh0","slug":"alpha-task","status":"ready-to-start","epic":"01-fixture-epic","description":"A fully specified ready-to-start task for golden snapshots","effort":"S","tier":2,"priority":"high","autonomy_level":3,"created":"2026-01-02","updated_at":"2026-01-03","tags":["cli","testing"]},{"id":"6fjangd7kvh1","slug":"beta-task","status":"in-progress","epic":"01-fixture-epic","description":"An in-progress fixture task","effort":"M","tier":3,"priority":"medium","autonomy_level":2,"created":"2026-01-02","updated_at":"2026-01-03","tags":["cli"]},{"id":"6fjangd7kvh2","slug":"gamma-task","status":"completed","epic":"01-fixture-epic","description":"A completed fixture task","tier":1,"priority":"low","created":"2026-01-01","updated_at":"2026-01-02","tags":["docs"]}]} diff --git a/internal/cli/testdata/golden/task_path_json.golden b/internal/cli/testdata/golden/task_path_json.golden new file mode 100644 index 0000000..7d44b43 --- /dev/null +++ b/internal/cli/testdata/golden/task_path_json.golden @@ -0,0 +1 @@ +{"schema_version":"1.28","path":"/tasks/6fjangd7kvh0-alpha-task.md"} diff --git a/internal/cli/testdata/golden/task_show_json.golden b/internal/cli/testdata/golden/task_show_json.golden index 67868c2..d1f4828 100644 --- a/internal/cli/testdata/golden/task_show_json.golden +++ b/internal/cli/testdata/golden/task_show_json.golden @@ -1 +1 @@ -{"schema_version":"1.26","task":{"id":"6fjangd7kvh0","slug":"alpha-task","status":"ready-to-start","epic":"01-fixture-epic","description":"A fully specified ready-to-start task for golden snapshots","effort":"S","tier":2,"priority":"high","autonomy_level":3,"created":"2026-01-02","updated_at":"2026-01-03","tags":["cli","testing"]},"body":"# Alpha Task\n\nBody for the alpha fixture task.\n"} +{"schema_version":"1.28","task":{"id":"6fjangd7kvh0","slug":"alpha-task","status":"ready-to-start","epic":"01-fixture-epic","description":"A fully specified ready-to-start task for golden snapshots","effort":"S","tier":2,"priority":"high","autonomy_level":3,"created":"2026-01-02","updated_at":"2026-01-03","tags":["cli","testing"]},"body":"# Alpha Task\n\nBody for the alpha fixture task.\n"} diff --git a/internal/cli/testdata/golden/template_list_json.golden b/internal/cli/testdata/golden/template_list_json.golden index 8bf8006..d079c0f 100644 --- a/internal/cli/testdata/golden/template_list_json.golden +++ b/internal/cli/testdata/golden/template_list_json.golden @@ -1 +1 @@ -{"schema_version":"1.26","templates":[{"kind":"task","name":"default","description":"Standard task scaffold: objective, acceptance criteria, out-of-scope, related epic."},{"kind":"epic","name":"default","description":"Standard epic scaffold: goal, why-it's-its-own-epic, out-of-scope."},{"kind":"audit","name":"default","description":"Standard audit scaffold: findings + candidate tasks."},{"kind":"audit","name":"security","description":"Security review: threat model, checklist, severity-tagged findings."}]} +{"schema_version":"1.28","templates":[{"kind":"task","name":"default","description":"Standard task scaffold: objective, acceptance criteria, out-of-scope, related epic."},{"kind":"epic","name":"default","description":"Standard epic scaffold: goal, why-it's-its-own-epic, out-of-scope."},{"kind":"audit","name":"default","description":"Standard audit scaffold: findings + candidate tasks."},{"kind":"audit","name":"security","description":"Security review: threat model, checklist, severity-tagged findings."}]} diff --git a/internal/cli/testdata/golden/template_show_security_json.golden b/internal/cli/testdata/golden/template_show_security_json.golden index 827f648..913c5fa 100644 --- a/internal/cli/testdata/golden/template_show_security_json.golden +++ b/internal/cli/testdata/golden/template_show_security_json.golden @@ -1 +1 @@ -{"schema_version":"1.26","template":{"kind":"audit","name":"security","description":"Security review: threat model, checklist, severity-tagged findings."},"body":"\n# Security audit: \u003carea\u003e — \u003cdate\u003e\n\n\u003e Security review. Edit findings in place and flip each `**Status:**` as you work it.\n\n## Threat model\n\n- **Assets / trust boundaries:** \u003cwhat's worth protecting; where untrusted input crosses in\u003e\n- **Attacker \u0026 entry points:** \u003cwho, and through which surfaces\u003e\n\n## Review checklist\n\n- [ ] Authn / authz — every privileged path checks identity *and* permission\n- [ ] Input validation — untrusted input is parsed/escaped (injection, path traversal)\n- [ ] Secrets — no hard-coded creds; least-privilege tokens; nothing sensitive logged\n- [ ] Dependencies — known-vuln scan; versions pinned\n- [ ] Data at rest / in transit — encryption + safe defaults\n\n## Findings\n\n\u003c!-- One finding per issue, in this shape (un-fence it): --\u003e\n\n```\n#### H1. \u003ctitle\u003e · **Status:** open\n\n**File:** \u003cpath:line\u003e | **Component:** \u003ccomponent\u003e\n**Severity:** \u003ccritical|high|medium|low\u003e · **Effort:** \u003cXS|S|M|L\u003e · **Urgency:** \u003cacute|soon|eventually\u003e\n\n\u003cwhat's exploitable, the impact, and how\u003e\n\n**Recommendation:** \u003cthe fix\u003e\n```\n\n## Candidate tasks\n\n\u003c!-- Mirror each finding: ✅ done · ⚠️ partial · ⏳ open · ⛔ won't do --\u003e\n\n- ⏳ `tskflwctl task new \"\u003ctitle\u003e\" --epic \u003cid\u003e --tags security` — \u003cone line\u003e\n"} +{"schema_version":"1.28","template":{"kind":"audit","name":"security","description":"Security review: threat model, checklist, severity-tagged findings."},"body":"\n# Security audit: \u003carea\u003e — \u003cdate\u003e\n\n\u003e Security review. Edit findings in place and flip each `**Status:**` as you work it.\n\n## Threat model\n\n- **Assets / trust boundaries:** \u003cwhat's worth protecting; where untrusted input crosses in\u003e\n- **Attacker \u0026 entry points:** \u003cwho, and through which surfaces\u003e\n\n## Review checklist\n\n- [ ] Authn / authz — every privileged path checks identity *and* permission\n- [ ] Input validation — untrusted input is parsed/escaped (injection, path traversal)\n- [ ] Secrets — no hard-coded creds; least-privilege tokens; nothing sensitive logged\n- [ ] Dependencies — known-vuln scan; versions pinned\n- [ ] Data at rest / in transit — encryption + safe defaults\n\n## Findings\n\n\u003c!-- One finding per issue, in this shape (un-fence it): --\u003e\n\n```\n#### H1. \u003ctitle\u003e · **Status:** open\n\n**File:** \u003cpath:line\u003e | **Component:** \u003ccomponent\u003e\n**Severity:** \u003ccritical|high|medium|low\u003e · **Effort:** \u003cXS|S|M|L\u003e · **Urgency:** \u003cacute|soon|eventually\u003e\n\n\u003cwhat's exploitable, the impact, and how\u003e\n\n**Recommendation:** \u003cthe fix\u003e\n```\n\n## Candidate tasks\n\n\u003c!-- Mirror each finding: ✅ done · ⚠️ partial · ⏳ open · ⛔ won't do --\u003e\n\n- ⏳ `tskflwctl task new \"\u003ctitle\u003e\" --epic \u003cid\u003e --tags security` — \u003cone line\u003e\n"} diff --git a/internal/core/service_audit.go b/internal/core/service_audit.go index f4f9858..b8899d0 100644 --- a/internal/core/service_audit.go +++ b/internal/core/service_audit.go @@ -96,6 +96,12 @@ func (s *Service) ShowAudit(slug string) (domain.Audit, string, error) { return s.store.GetAudit(slug) } +// AuditPath resolves an audit's file path without reading or parsing it — the seam +// for `audit path` (parse-free, like TaskPath). +func (s *Service) AuditPath(slug string) (string, error) { + return s.store.ResolveAuditPath(slug) +} + // MoveAudit relocates an audit to another bucket (close/reopen/defer). func (s *Service) MoveAudit(slug string, to domain.AuditBucket, dryRun bool) (domain.Audit, error) { return retryOnConflict(s, dryRun, func() (domain.Audit, error) { diff --git a/internal/core/service_epic.go b/internal/core/service_epic.go index 91d3265..e91c98a 100644 --- a/internal/core/service_epic.go +++ b/internal/core/service_epic.go @@ -380,3 +380,9 @@ func (s *Service) ShowEpic(id string) (EpicSummary, []domain.Task, string, error } return rollupEpic(epic, its), its, body, nil } + +// EpicPath resolves an epic's file path without reading or parsing it — the seam +// for `epic path` (parse-free, like TaskPath). +func (s *Service) EpicPath(id string) (string, error) { + return s.store.ResolveEpicPath(id) +} diff --git a/internal/core/service_epic_test.go b/internal/core/service_epic_test.go index eab4321..843d472 100644 --- a/internal/core/service_epic_test.go +++ b/internal/core/service_epic_test.go @@ -20,6 +20,9 @@ func (nopStore) ListTasks() ([]domain.Task, []domain.FileProblem, error) { retur func (nopStore) GetTask(string) (domain.Task, string, error) { return domain.Task{}, "", domain.ErrNotFound } +func (nopStore) ResolveTaskPath(string) (string, error) { return "", domain.ErrNotFound } +func (nopStore) ResolveEpicPath(string) (string, error) { return "", domain.ErrNotFound } +func (nopStore) ResolveAuditPath(string) (string, error) { return "", domain.ErrNotFound } func (nopStore) Move(string, domain.Status, time.Time, bool) (domain.Task, error) { return domain.Task{}, nil } diff --git a/internal/core/service_task.go b/internal/core/service_task.go index 62e846e..a1665cc 100644 --- a/internal/core/service_task.go +++ b/internal/core/service_task.go @@ -77,6 +77,12 @@ func (s *Service) ShowTask(slug string) (domain.Task, string, error) { return s.store.GetTask(slug) } +// TaskPath resolves a task's file path without reading or parsing it — the seam +// for `task path`, which must work even on a file with broken frontmatter. +func (s *Service) TaskPath(slug string) (string, error) { + return s.store.ResolveTaskPath(slug) +} + // EditTask opens a task for whole-file editing — the human face of mutation, // complementing the field-level `task set`. edit (run by the cli's $EDITOR layer) // receives the current file content and returns the new content; the store diff --git a/internal/core/store.go b/internal/core/store.go index 09f9f5d..ec45bc3 100644 --- a/internal/core/store.go +++ b/internal/core/store.go @@ -15,6 +15,10 @@ import ( type TaskStore interface { ListTasks() ([]domain.Task, []domain.FileProblem, error) GetTask(slug string) (task domain.Task, body string, err error) + // ResolveTaskPath returns a task's file path from its slug/id WITHOUT parsing — + // so `task path` works even on a file whose frontmatter won't parse (the case + // where you most need the path, to open and repair it). + ResolveTaskPath(slug string) (string, error) // Mutators take dryRun: true runs EVERY validation (resolve, parse-before- // commit, collision/CAS checks) and returns the would-be result, but stops // short of touching disk — so a dry-run that would fail fails identically. @@ -49,6 +53,9 @@ type TaskStore interface { type EpicStore interface { ListEpics() ([]domain.Epic, []domain.FileProblem, error) GetEpic(id string) (epic domain.Epic, body string, err error) + // ResolveEpicPath returns an epic's file path from its id, parse-free (see + // ResolveTaskPath). + ResolveEpicPath(id string) (string, error) CreateEpic(slug string, e domain.Epic, body string, dryRun bool) (domain.Epic, error) // MoveEpic surgically rewrites an epic's `status` frontmatter field (epic // status is a field, not a directory, so the file stays put), stamping updated_at @@ -88,6 +95,9 @@ type AuditStore interface { // FileProblem, not fatal. ListAuditsWithFindings() ([]AuditWithFindings, []domain.FileProblem, error) GetAudit(slug string) (audit domain.Audit, body string, err error) + // ResolveAuditPath returns an audit's file path from its slug/id, parse-free + // (see ResolveTaskPath). + ResolveAuditPath(slug string) (string, error) // GetAuditByPath reads one audit directly by its file path (bucket read from // frontmatter, ADR-0003 §4) rather than re-resolving the slug. The finding/lint // sweeps use this to read each audit ListAudits already located exactly once, diff --git a/internal/domain/body.go b/internal/domain/body.go new file mode 100644 index 0000000..4c92ea3 --- /dev/null +++ b/internal/domain/body.go @@ -0,0 +1,157 @@ +package domain + +import ( + "regexp" + "strings" +) + +// ACCount is the acceptance-criteria checkbox tally of a task body: how many of +// the criteria are checked out of the total. A body with no acceptance-criteria +// section (or one with no checkboxes) has a zero tally. +type ACCount struct { + Checked int + Total int +} + +// The body-structure model is line-oriented and code-fence aware: a `##` heading +// or a `- [ ]` checkbox inside a fenced block is example prose, not structure, so +// the scanners skip fenced lines — the same "don't treat code as structure" +// discipline scanLinks uses for links. +var ( + bodyHeadingRe = regexp.MustCompile(`^(#{1,6})[ \t]+(.*\S)[ \t]*$`) + bodyCheckboxRe = regexp.MustCompile(`^[ \t]*[-*+][ \t]+\[([ xX])\]`) +) + +// fenceAt reports whether line is a fenced-code delimiter: a run of >=3 backticks +// or tildes after optional indentation. It returns the fence character, the run +// length, and the text after the run — an info string on an OPENING fence, which +// must be blank for a valid CLOSING fence. ok is false for any non-fence line. +func fenceAt(line string) (marker byte, length int, rest string, ok bool) { + i := 0 + for i < len(line) && (line[i] == ' ' || line[i] == '\t') { + i++ + } + if i >= len(line) || (line[i] != '`' && line[i] != '~') { + return 0, 0, "", false + } + marker = line[i] + j := i + for j < len(line) && line[j] == marker { + j++ + } + if j-i < 3 { + return 0, 0, "", false + } + return marker, j - i, line[j:], true +} + +// fenceScanner tracks fenced-code state across a body's lines. It honors the fence +// CHARACTER and LENGTH rather than a naive toggle: a block closes only on a line of +// the same marker, at least as long, with no trailing info string — so a +// shorter/different inner fence, or an info-stringed line like ```go, stays INSIDE +// the block (CommonMark §4.5). A naive `inFence = !inFence` toggle would wrongly +// close on either and leak the nested content into structure scanning. +type fenceScanner struct { + open bool + marker byte + length int +} + +// inCode advances the scanner by one line and reports whether that line is code (a +// fence delimiter or content inside a fence) and so must be skipped by structure +// scanning. +func (f *fenceScanner) inCode(line string) bool { + m, l, rest, ok := fenceAt(line) + if !ok { + return f.open + } + if !f.open { + f.open, f.marker, f.length = true, m, l + return true + } + if m == f.marker && l >= f.length && strings.TrimSpace(rest) == "" { + f.open = false + } + return true +} + +// Section returns the markdown block for the FIRST heading whose title contains +// name (case-insensitive), from that heading through the line before the next +// heading of the same or higher level — nested deeper headings stay inside. +// Trailing blank lines are trimmed. ok is false when no heading matches. Headings +// inside fenced code blocks are ignored so an example `##` never matches. CRLF line +// endings are tolerated (normalized to LF). +func Section(body, name string) (text string, ok bool) { + lines := strings.Split(normalizeNewlines(body), "\n") + q := strings.ToLower(strings.TrimSpace(name)) + start, level := -1, 0 + var fence fenceScanner + for i, ln := range lines { + if fence.inCode(ln) { + continue + } + m := bodyHeadingRe.FindStringSubmatch(ln) + if m == nil { + continue + } + lvl := len(m[1]) + if start == -1 { + if strings.Contains(strings.ToLower(m[2]), q) { + start, level = i, lvl + } + continue + } + if lvl <= level { // a same-or-higher heading closes the section + return trimTrailingBlankLines(lines[start:i]), true + } + } + if start == -1 { + return "", false + } + return trimTrailingBlankLines(lines[start:]), true +} + +// CountAcceptanceCriteria tallies the task-list checkboxes inside the body's +// acceptance-criteria section (the first heading whose title contains +// "acceptance"). No such section — or none with checkboxes — yields a zero tally. +// Checkboxes in fenced code blocks are ignored. +func CountAcceptanceCriteria(body string) ACCount { + sec, ok := Section(body, "acceptance") // Section already normalizes newlines + if !ok { + return ACCount{} + } + var c ACCount + var fence fenceScanner + for _, ln := range strings.Split(sec, "\n") { + if fence.inCode(ln) { + continue + } + m := bodyCheckboxRe.FindStringSubmatch(ln) + if m == nil { + continue + } + c.Total++ + if m[1] == "x" || m[1] == "X" { + c.Checked++ + } + } + return c +} + +// normalizeNewlines folds CRLF (and lone CR) to LF so the line-oriented scanners +// don't miss `\r`-terminated headings — files touched on Windows or under a +// core.autocrlf checkout, or a CRLF body piped through --body-file. +func normalizeNewlines(s string) string { + if !strings.ContainsRune(s, '\r') { + return s + } + return strings.ReplaceAll(strings.ReplaceAll(s, "\r\n", "\n"), "\r", "\n") +} + +func trimTrailingBlankLines(lines []string) string { + end := len(lines) + for end > 0 && strings.TrimSpace(lines[end-1]) == "" { + end-- + } + return strings.Join(lines[:end], "\n") +} diff --git a/internal/domain/body_test.go b/internal/domain/body_test.go new file mode 100644 index 0000000..fdede7a --- /dev/null +++ b/internal/domain/body_test.go @@ -0,0 +1,133 @@ +package domain + +import ( + "strings" + "testing" +) + +const acBody = `# Title + +Some intro prose. + +## Acceptance criteria + +- [x] first is done +- [ ] second is not +- [X] third is done (capital X) + +## Notes + +- [ ] this checkbox is NOT acceptance criteria and must not count +` + +func TestCountAcceptanceCriteria(t *testing.T) { + got := CountAcceptanceCriteria(acBody) + if got.Checked != 2 || got.Total != 3 { + t.Fatalf("CountAcceptanceCriteria = %+v, want {Checked:2 Total:3}", got) + } +} + +func TestCountAcceptanceCriteria_NoSection(t *testing.T) { + if got := CountAcceptanceCriteria("# Title\n\njust prose, no criteria\n"); got != (ACCount{}) { + t.Fatalf("no AC section should be zero tally, got %+v", got) + } +} + +func TestCountAcceptanceCriteria_IgnoresFencedCheckboxes(t *testing.T) { + body := "## Acceptance criteria\n\n- [x] real one\n\n```\n- [ ] fenced example, not real\n```\n" + if got := CountAcceptanceCriteria(body); got.Checked != 1 || got.Total != 1 { + t.Fatalf("fenced checkbox must not count, got %+v", got) + } +} + +func TestSection(t *testing.T) { + sec, ok := Section(acBody, "acceptance") + if !ok { + t.Fatal("expected to find the acceptance section") + } + want := "## Acceptance criteria\n\n- [x] first is done\n- [ ] second is not\n- [X] third is done (capital X)" + if sec != want { + t.Fatalf("Section(acceptance) =\n%q\nwant\n%q", sec, want) + } +} + +func TestSection_TrailingSectionRunsToEnd(t *testing.T) { + sec, ok := Section(acBody, "notes") + if !ok { + t.Fatal("expected to find the notes section") + } + want := "## Notes\n\n- [ ] this checkbox is NOT acceptance criteria and must not count" + if sec != want { + t.Fatalf("Section(notes) =\n%q\nwant\n%q", sec, want) + } +} + +func TestSection_NestedDeeperHeadingsStayInside(t *testing.T) { + body := "## Design\n\ntop.\n\n### Sub\n\nnested.\n\n## After\n\nout.\n" + sec, ok := Section(body, "design") + if !ok { + t.Fatal("expected to find the design section") + } + want := "## Design\n\ntop.\n\n### Sub\n\nnested." + if sec != want { + t.Fatalf("Section(design) =\n%q\nwant\n%q", sec, want) + } +} + +func TestSection_NotFound(t *testing.T) { + if _, ok := Section(acBody, "nonexistent"); ok { + t.Fatal("expected no match for a missing section") + } +} + +// CRLF line endings (Windows / core.autocrlf checkout / a CRLF --body-file) must +// not blind the heading + checkbox scanners. +func TestCountAcceptanceCriteria_CRLF(t *testing.T) { + body := "# Title\r\n\r\n## Acceptance criteria\r\n\r\n- [x] a\r\n- [ ] b\r\n" + if got := CountAcceptanceCriteria(body); got.Checked != 1 || got.Total != 2 { + t.Fatalf("CRLF body tally = %+v, want {Checked:1 Total:2}", got) + } +} + +func TestSection_CRLF(t *testing.T) { + body := "## Notes\r\n\r\nsome text\r\n" + sec, ok := Section(body, "notes") + if !ok || sec != "## Notes\n\nsome text" { + t.Fatalf("Section on CRLF body = (%q, %v), want normalized LF block", sec, ok) + } +} + +// A fenced block that contains an info-stringed inner fence (```go inside +// ```markdown) or a shorter inner fence must NOT close the outer block — a naive +// boolean toggle would, leaking the fenced `##`/`- [ ]` into structure scanning. +func TestFenceScanner_NestedFencesDoNotLeak(t *testing.T) { + body := "## Acceptance criteria\n\n" + + "```markdown\n" + + "```go\n" + + "## Fake nested heading\n" + + "- [ ] fenced example, not a real criterion\n" + + "```\n\n" + + "- [x] the only real criterion\n" + // The fenced `## Fake nested heading` must not truncate the section... + sec, ok := Section(body, "acceptance") + if !ok || !strings.Contains(sec, "the only real criterion") { + t.Fatalf("nested fence truncated the section:\n%q", sec) + } + // ...and the fenced `- [ ]` must not count. + if got := CountAcceptanceCriteria(body); got.Checked != 1 || got.Total != 1 { + t.Fatalf("nested-fence tally = %+v, want {Checked:1 Total:1}", got) + } +} + +func TestSection_IgnoresFencedHeadings(t *testing.T) { + body := "## Real\n\ntext.\n\n```\n## Fake heading in a fence\n```\n\nmore text under Real.\n" + sec, ok := Section(body, "real") + if !ok { + t.Fatal("expected to find the real section") + } + // The fenced `## Fake` must not close the section — everything is one block. + want := "## Real\n\ntext.\n\n```\n## Fake heading in a fence\n```\n\nmore text under Real." + if sec != want { + t.Fatalf("Section(real) =\n%q\nwant\n%q", sec, want) + } +} diff --git a/internal/store/paths.go b/internal/store/paths.go new file mode 100644 index 0000000..54cf705 --- /dev/null +++ b/internal/store/paths.go @@ -0,0 +1,15 @@ +package store + +// Path resolvers: slug/id → absolute file path WITHOUT reading or parsing the file. +// The ` path` commands use these so they still work on a file whose +// frontmatter won't parse — the case where you most need the path (to open and +// repair it). GetTask/GetEpic/GetAudit would fail at the parse step first. + +// ResolveTaskPath returns a task's file path from its slug/id, parse-free. +func (s *FS) ResolveTaskPath(slug string) (string, error) { return s.resolve(slug) } + +// ResolveEpicPath returns an epic's file path from its id, parse-free. +func (s *FS) ResolveEpicPath(id string) (string, error) { return s.resolveEpicPath(id) } + +// ResolveAuditPath returns an audit's file path from its slug/id, parse-free. +func (s *FS) ResolveAuditPath(slug string) (string, error) { return s.resolveAudit(slug) } diff --git a/internal/wire/dto.go b/internal/wire/dto.go index bdd58b7..93e60d5 100644 --- a/internal/wire/dto.go +++ b/internal/wire/dto.go @@ -49,6 +49,66 @@ func ToTaskJSON(t domain.Task) TaskJSON { return j } +// ACJSON is a task's acceptance-criteria checkbox tally (the `ac` field of +// `task info`): how many criteria are checked out of the total. +type ACJSON struct { + Checked int `json:"checked" jsonschema:"description=acceptance criteria currently checked (- [x])"` + Total int `json:"total" jsonschema:"description=total acceptance criteria in the task's acceptance-criteria section"` +} + +// TaskInfoJSON is the token-cheap metadata read for a task (`task info`): where +// the file lives plus the fields an agent triages on and the acceptance-criteria +// tally, WITHOUT the body — the machine counterpart to `task path` that avoids the +// full `task show` payload. +type TaskInfoJSON struct { + ID string `json:"id,omitempty" jsonschema:"description=stable identifier — absent on tasks created before id assignment"` + Slug string `json:"slug" jsonschema:"description=task slug (filename without .md)"` + Status string `json:"status" jsonschema:"description=lifecycle status — authoritative from frontmatter (ADR-0003 §4)"` + Epic string `json:"epic,omitempty" jsonschema:"description=id of the epic this task belongs to"` + Path string `json:"path" jsonschema:"description=absolute path to the task's markdown file"` + AC ACJSON `json:"ac" jsonschema:"description=acceptance-criteria checkbox tally"` +} + +// ToTaskInfoJSON maps a task + its acceptance tally + resolved path to the info DTO. +func ToTaskInfoJSON(t domain.Task, ac domain.ACCount, path string) TaskInfoJSON { + return TaskInfoJSON{ + ID: t.ID, Slug: t.Slug, Status: string(t.Status), Epic: t.Epic, + Path: path, AC: ACJSON{Checked: ac.Checked, Total: ac.Total}, + } +} + +// FindingsTallyJSON is an audit's finding disposition tally (the `findings` field +// of `audit info`) — the audit analogue of a task's acceptance-criteria tally. +type FindingsTallyJSON struct { + Total int `json:"total" jsonschema:"description=total findings parsed from the audit body"` + Open int `json:"open" jsonschema:"description=findings whose status is open"` + InProgress int `json:"in_progress" jsonschema:"description=findings whose status is in-progress"` + Done int `json:"done" jsonschema:"description=findings whose status is fixed or landed"` + Dropped int `json:"dropped" jsonschema:"description=findings whose status is deferred, superseded, or wontfix"` +} + +// AuditInfoJSON is the token-cheap metadata read for an audit (`audit info`): where +// the file lives, its bucket, and the finding tally — no body. +type AuditInfoJSON struct { + ID string `json:"id,omitempty" jsonschema:"description=stable identifier — absent on audits created before id assignment"` + Slug string `json:"slug" jsonschema:"description=audit slug (filename without .md)"` + Bucket string `json:"bucket" jsonschema:"description=open | closed | deferred — authoritative from frontmatter (ADR-0003 §4)"` + Path string `json:"path" jsonschema:"description=absolute path to the audit's markdown file"` + Findings FindingsTallyJSON `json:"findings" jsonschema:"description=finding disposition tally"` +} + +// ToAuditInfoJSON maps an audit (whose disposition tally is populated on load) + its +// resolved path to the info DTO. +func ToAuditInfoJSON(a domain.Audit, path string) AuditInfoJSON { + return AuditInfoJSON{ + ID: a.ID, Slug: a.Slug, Bucket: string(a.Bucket), Path: path, + Findings: FindingsTallyJSON{ + Total: a.Findings, Open: a.OpenFindings, InProgress: a.ActiveFindings, + Done: a.DoneFindings, Dropped: a.DroppedFindings, + }, + } +} + // StatusCountJSON is one status bucket and its task count. type StatusCountJSON struct { Status string `json:"status"` diff --git a/internal/wire/envelopes.go b/internal/wire/envelopes.go index 3cce94d..7331c42 100644 --- a/internal/wire/envelopes.go +++ b/internal/wire/envelopes.go @@ -82,6 +82,44 @@ func ToTaskShowEnvelope(t domain.Task, body string) TaskShowEnvelope { return TaskShowEnvelope{SchemaVersion: SchemaVersion, Task: ToTaskJSON(t), Body: body} } +// TaskInfoEnvelope wraps `task info --json` — the token-cheap task metadata read +// (file path + triage fields + acceptance tally, no body). +type TaskInfoEnvelope struct { + SchemaVersion string `json:"schema_version"` + TaskInfo TaskInfoJSON `json:"task_info"` +} + +// ToTaskInfoEnvelope builds the `task info --json` envelope value. +func ToTaskInfoEnvelope(t domain.Task, ac domain.ACCount, path string) TaskInfoEnvelope { + return TaskInfoEnvelope{SchemaVersion: SchemaVersion, TaskInfo: ToTaskInfoJSON(t, ac, path)} +} + +// PathEnvelope wraps `task path --json` — just the resolved absolute file path +// (the plain form prints the bare path for piping, e.g. `$EDITOR "$(… task path +// x)"`; --json wraps it so the contract's "schema_version everywhere" holds). +type PathEnvelope struct { + SchemaVersion string `json:"schema_version"` + Path string `json:"path" jsonschema:"description=absolute path to the entity's markdown file"` +} + +// ToPathEnvelope builds the ` path --json` envelope value (task/epic/audit +// path all emit this — just the resolved absolute file path). +func ToPathEnvelope(path string) PathEnvelope { + return PathEnvelope{SchemaVersion: SchemaVersion, Path: path} +} + +// AuditInfoEnvelope wraps `audit info --json` — the token-cheap audit metadata read +// (path + bucket + finding tally, no body), the audit counterpart to TaskInfoEnvelope. +type AuditInfoEnvelope struct { + SchemaVersion string `json:"schema_version"` + AuditInfo AuditInfoJSON `json:"audit_info"` +} + +// ToAuditInfoEnvelope builds the `audit info --json` envelope value. +func ToAuditInfoEnvelope(a domain.Audit, path string) AuditInfoEnvelope { + return AuditInfoEnvelope{SchemaVersion: SchemaVersion, AuditInfo: ToAuditInfoJSON(a, path)} +} + // TaskMutationEnvelope is `task set` / `task append` / `task set --body` under // --json: the reloaded task, dry_run, and (for the body commands) the resulting // body. Separate from TaskShowEnvelope so the mutation-only dry_run stays off the @@ -537,6 +575,8 @@ type jsonEnvelopes struct { Tasks TasksEnvelope `json:"tasks"` Board BoardEnvelope `json:"board"` TaskShow TaskShowEnvelope `json:"task_show"` + TaskInfo TaskInfoEnvelope `json:"task_info"` + Path PathEnvelope `json:"path"` TaskMutation TaskMutationEnvelope `json:"task_mutation"` EpicMutation EpicMutationEnvelope `json:"epic_mutation"` Moves MovesEnvelope `json:"moves"` @@ -547,6 +587,7 @@ type jsonEnvelopes struct { EpicShow EpicShowEnvelope `json:"epic_show"` Audits AuditsEnvelope `json:"audits"` AuditShow AuditShowEnvelope `json:"audit_show"` + AuditInfo AuditInfoEnvelope `json:"audit_info"` AuditMutation AuditMutationEnvelope `json:"audit_mutation"` Findings FindingsEnvelope `json:"findings"` Fix FixEnvelope `json:"fix"` diff --git a/internal/wire/envelopes_test.go b/internal/wire/envelopes_test.go index 0870f99..8de0027 100644 --- a/internal/wire/envelopes_test.go +++ b/internal/wire/envelopes_test.go @@ -54,6 +54,13 @@ func TestJSONSchema_ValidatesRealOutput(t *testing.T) { return emit(w, ToBoardEnvelope(core.Board{Columns: []core.BoardColumn{{Status: domain.StatusInProgress, Tasks: []domain.Task{task}}}})) }}, {"TaskShowEnvelope", func(w io.Writer) error { return emit(w, ToTaskShowEnvelope(task, "# body")) }}, + {"TaskInfoEnvelope", func(w io.Writer) error { + return emit(w, ToTaskInfoEnvelope(task, domain.ACCount{Checked: 1, Total: 3}, "/root/tasks/alpha.md")) + }}, + {"PathEnvelope", func(w io.Writer) error { return emit(w, ToPathEnvelope("/root/tasks/alpha.md")) }}, + {"AuditInfoEnvelope", func(w io.Writer) error { + return emit(w, ToAuditInfoEnvelope(domain.Audit{Slug: "x", Bucket: domain.AuditOpen, Findings: 3, OpenFindings: 1, ActiveFindings: 1, DoneFindings: 1}, "/root/audits/x.md")) + }}, {"TaskMutationEnvelope", func(w io.Writer) error { return emit(w, ToTaskMutationEnvelope(task, "# new body", true)) }}, {"EpicMutationEnvelope", func(w io.Writer) error { return emit(w, ToEpicMutationEnvelope(epic, true)) }}, {"CreatedEnvelope", func(w io.Writer) error { diff --git a/internal/wire/schema_comments.json b/internal/wire/schema_comments.json index bdd032f..4ba51f8 100644 --- a/internal/wire/schema_comments.json +++ b/internal/wire/schema_comments.json @@ -1,4 +1,5 @@ { + "github.com/andy-esch/taskflow/internal/domain.ACCount": "ACCount is the acceptance-criteria checkbox tally of a task body: how many of the criteria are checked out of the total.", "github.com/andy-esch/taskflow/internal/domain.Audit": "Audit is a code-audit document.", "github.com/andy-esch/taskflow/internal/domain.Audit.ActiveFindings": "status: in-progress", "github.com/andy-esch/taskflow/internal/domain.Audit.Bucket": "Bucket is the audit's lifecycle state — authoritative, read from frontmatter.", @@ -48,6 +49,9 @@ "github.com/andy-esch/taskflow/internal/domain.Transition": "Transition is one lifecycle action: the verb a user names (from the CLI or the TUI action menu) mapped to the destination state it moves a document to.", "github.com/andy-esch/taskflow/internal/domain.TransitionParam": "TransitionParam marks an optional argument a lifecycle verb accepts beyond the move itself.", "github.com/andy-esch/taskflow/internal/domain.UnsetField": "UnsetField is a sentinel value in a SetFields update map: the key is removed from the frontmatter instead of being assigned.", + "github.com/andy-esch/taskflow/internal/wire.ACJSON": "ACJSON is a task's acceptance-criteria checkbox tally (the `ac` field of `task info`): how many criteria are checked out of the total.", + "github.com/andy-esch/taskflow/internal/wire.AuditInfoEnvelope": "AuditInfoEnvelope wraps `audit info --json` — the token-cheap audit metadata read (path + bucket + finding tally, no body), the audit counterpart to TaskInfoEnvelope.", + "github.com/andy-esch/taskflow/internal/wire.AuditInfoJSON": "AuditInfoJSON is the token-cheap metadata read for an audit (`audit info`): where the file lives, its bucket, and the finding tally — no body.", "github.com/andy-esch/taskflow/internal/wire.AuditJSON": "AuditJSON is the wire shape of an audit inside the --json envelopes.", "github.com/andy-esch/taskflow/internal/wire.AuditJSON.InProgressFindings": "The progress bar's disposition bands. open + in_progress + done + dropped ≤\nfindings (an unrecognized/missing status counts toward none).", "github.com/andy-esch/taskflow/internal/wire.AuditJSON.ReadyToClose": "ReadyToClose is true for an OPEN audit whose findings are all resolved/dropped\n(none open or in-progress) — a \"ready to close\" call-to-action.", @@ -74,6 +78,7 @@ "github.com/andy-esch/taskflow/internal/wire.FindingJSON": "FindingJSON is the wire shape of one audit finding.", "github.com/andy-esch/taskflow/internal/wire.FindingsEnvelope": "FindingsEnvelope is `audit findings --json` (the finding-level query).", "github.com/andy-esch/taskflow/internal/wire.FindingsRollupJSON": "FindingsRollupJSON aggregates the actionable audit findings (status open or in-progress) across all audits — the `status` summary's \"audit findings\" view.", + "github.com/andy-esch/taskflow/internal/wire.FindingsTallyJSON": "FindingsTallyJSON is an audit's finding disposition tally (the `findings` field of `audit info`) — the audit analogue of a task's acceptance-criteria tally.", "github.com/andy-esch/taskflow/internal/wire.FixEnvelope": "FixEnvelope is `lint --fix --json`.", "github.com/andy-esch/taskflow/internal/wire.InitEnvelope": "InitEnvelope is `init --json`.", "github.com/andy-esch/taskflow/internal/wire.KindSchema": "KindSchema is the per-kind authoring guidance (`tskflwctl schema \u003ckind\u003e`): how to compose a well-formed document of that kind.", @@ -81,6 +86,7 @@ "github.com/andy-esch/taskflow/internal/wire.LintTaskJSON": "LintTaskJSON is one entity's lint result (slug + field issues), the shape the `lint` / `audit lint` / `fix --remaining` envelopes carry per entity.", "github.com/andy-esch/taskflow/internal/wire.MoveResult": "MoveResult is the per-item outcome of a transition.", "github.com/andy-esch/taskflow/internal/wire.MovesEnvelope": "MovesEnvelope is the transition report (`task start --json`, etc.).", + "github.com/andy-esch/taskflow/internal/wire.PathEnvelope": "PathEnvelope wraps `task path --json` — just the resolved absolute file path (the plain form prints the bare path for piping, e.g.", "github.com/andy-esch/taskflow/internal/wire.SchemaContract": "SchemaContract is the global machine contract (`tskflwctl schema`): everything an agent needs to drive the tool without parsing --help prose.", "github.com/andy-esch/taskflow/internal/wire.SchemaEnvelope": "SchemaEnvelope is `schema --json` (the global contract).", "github.com/andy-esch/taskflow/internal/wire.SchemaExitCode": "SchemaExitCode is one exit code and its stable machine name (also the `code` in the --json error envelope).", @@ -89,6 +95,8 @@ "github.com/andy-esch/taskflow/internal/wire.SchemaStatus": "SchemaStatus is one task status and whether it is part of the working set.", "github.com/andy-esch/taskflow/internal/wire.StatusCountJSON": "StatusCountJSON is one status bucket and its task count.", "github.com/andy-esch/taskflow/internal/wire.SummaryEnvelope": "SummaryEnvelope is `status --json`.", + "github.com/andy-esch/taskflow/internal/wire.TaskInfoEnvelope": "TaskInfoEnvelope wraps `task info --json` — the token-cheap task metadata read (file path + triage fields + acceptance tally, no body).", + "github.com/andy-esch/taskflow/internal/wire.TaskInfoJSON": "TaskInfoJSON is the token-cheap metadata read for a task (`task info`): where the file lives plus the fields an agent triages on and the acceptance-criteria tally, WITHOUT the body — the machine counterpart to `task path` that avoids the full `task show` payload.", "github.com/andy-esch/taskflow/internal/wire.TaskJSON": "TaskJSON is the wire shape of a task inside the --json envelopes.", "github.com/andy-esch/taskflow/internal/wire.TaskJSON.Description": "The \"\u003c=200\" cap can't be computed (struct tags are static literals) — the only\nhardcoded copy of domain.MaxDescriptionLen left. Kept honest by\nTestTaskJSONDescriptionTagMatchesCap; update both if the cap changes.", "github.com/andy-esch/taskflow/internal/wire.TaskMutationEnvelope": "TaskMutationEnvelope is `task set` / `task append` / `task set --body` under --json: the reloaded task, dry_run, and (for the body commands) the resulting body.", diff --git a/internal/wire/wire.go b/internal/wire/wire.go index b2f293f..5d11107 100644 --- a/internal/wire/wire.go +++ b/internal/wire/wire.go @@ -95,7 +95,14 @@ import ( // 1.26: retired the task `misfiled`/`declared_status` fields and the `status` summary // `misfiled` count — the flat, id-led layout (ADR-0003 §4) removes the directory mirror // entirely, so a task/audit can never be misfiled (status/bucket live only in frontmatter). -const SchemaVersion = "1.26" +// 1.27: the `task_info` envelope (`task info` — token-cheap metadata read: path + +// triage fields + acceptance-criteria tally `ac:{checked,total}`, no body) and the +// `path` envelope (`task path --json` — the resolved absolute file path) added. +// 1.28: the `audit_info` envelope (`audit info` — token-cheap audit metadata: path + +// bucket + finding disposition tally `findings:{total,open,in_progress,done,dropped}`, +// no body; the audit counterpart to `task_info`) added. The `path` envelope now also +// backs `epic path` / `audit path` (unchanged shape). +const SchemaVersion = "1.28" // EncodeJSON writes the payload as compact (un-indented) JSON with a single // trailing newline. Machine output: pretty-printing is pure token cost for a diff --git a/planning/tasks/6fpfcecdymca-structure-aware-body-mutation-and-metadata-reads.md b/planning/tasks/6fpfcecdymca-structure-aware-body-mutation-and-metadata-reads.md new file mode 100644 index 0000000..f680f56 --- /dev/null +++ b/planning/tasks/6fpfcecdymca-structure-aware-body-mutation-and-metadata-reads.md @@ -0,0 +1,229 @@ +--- +schema: 1 +id: 6fpfcecdymca +status: in-progress +epic: 20-cli-ux-and-ergonomics +description: 'Structure-aware body mutation + metadata reads (task log/ac, task path/info, show --section): the read-only cluster shipped' +effort: Unknown +tier: 3 +priority: medium +autonomy_level: 3 +tags: [cli, agents, ux, dx] +created: "2026-07-15" +started_at: "2026-07-15" +updated_at: "2026-07-16" +--- +> ⚠️ **Externally proposed — filed 2026-07-15** from a second agent dogfooding +> session — the sequel to +> [agent-facing-cli-ergonomics-batch](6fbj87000anh-agent-facing-cli-ergonomics-batch.md), +> which already shipped `--body-file`, `task append`, `task set --body`, and the +> create envelope. These are the friction points that bit *this* round, in the +> agent's own impact order. The unifying thread: **the CLI still treats the body +> as an opaque append-only blob.** The wins are in teaching it that the AC list +> and the Progress Log are *structural sections*, plus a cheap file-location / +> metadata read. + +## Objective + +1. **`task log ` — dated Progress Log append (the headline).** Adding a + progress entry is the agent's single most frequent write, and `task append` + is structure-blind: it re-declares `## Progress Log` when one already exists, + producing a duplicate header that then needs a hand-merge. `task log + --body "…"` (and `--body-file -`) should (a) append a bullet under the + *existing* Progress Log section, creating the section only if absent, and + (b) auto-stamp today's date. Kills both the duplicate-header footgun and the + manual date-typing. +2. **`task ac ` — acceptance-criteria checkboxes via CLI.** Closing a task + today means finding the file and hand-flipping `- [ ]` → `- [x]` in an editor. + Add `task ac --check ` / `--uncheck `, with `task ac + --list` printing the criteria numbered. Index-based matching is the robust + form (substring matching is fiddly): `--list`, then `--check 3`. +3. **`task path` / `task info --json` — file location + cheap metadata.** The + agent fell back to `find` every time it needed body content the CLI can't + reach (items 1–2), and globbing on a slug substring is fragile because files + are `-.md`. `task path ` prints just the absolute path + (pipe-friendly). `task info --json` returns `{path, status, epic, + bucket, ac:{checked, total}}` — also the token-cheap metadata read for item 4. + (This is the `task path` / `task info` idea floated earlier but never filed — + confirmed absent from planning.) +4. **Section-scoped / metadata-only reads.** `task show` is token-heavy when the + agent only wants the frontmatter, the ACs, or the Progress Log — it ends up + piping to `grep`/`sed`/`head`. Add `task show --section ` (e.g. + `acceptance`, `progress`) and `--frontmatter-only`. Complements the terse + `task list -o table -c` steering already in the schema guidance. + +## Already ships — advertise, don't build + +The agent also asked for two things **that already exist** — a discoverability +gap, not a missing feature. The fix is steering (help/README/docs), not code: + +- **Non-interactive whole-body replace** → `task set --body`/`--body-file -` + already does exactly this (prior batch; its own atomic write, mutually + exclusive with field flags). +- **`task new --body-file -`** → already exists, and already collapses + new+append into one call. Only "create *and defer* in one call" / an initial + `--status` beyond `--next`/`--start` is genuinely absent, and that's minor. + +Make sure `--help`, the README "agent use" section, and the generated `docs/cli` +reference actually surface `task set --body-file -` and `task new --body-file -` +so the next agent finds them without dogfooding-by-discovery. + +## Acceptance criteria + +- [ ] `task log --body|--body-file -` appends a dated bullet under + `## Progress Log`, creating the section only when absent — never a + duplicate header. Atomic; `--json` returns the task envelope; `--dry-run` + previews. +- [ ] `task ac --list` numbers the criteria; `--check `/`--uncheck ` + flip the box atomically, preserving surrounding body; `--json` supported. +- [x] `task path ` prints the absolute file path and nothing else. +- [x] `task info --json` returns `{path, status, epic, + ac:{checked, total}}` (+ `id`/`slug`), validating against the published JSON + schema. (No `bucket` — tasks have no bucket under the flat id-led layout; + `status` is authoritative.) +- [x] `task show --section ` and `--frontmatter-only` project a + single section without the rest of the body. +- [x] `--help`, README, and the generated `docs/cli` reference advertise the + already-shipped `task set --body-file -` and `task new --body-file -`. (The + `schema` command is the data-model contract — statuses/fields/exit codes — + not a command index, so it is *not* one of these surfaces.) +- [x] Suite + lint green; docs regenerated (`docgen`). + +## Design notes / risks + +- **Structure-awareness is the shared engine.** Items 1, 2, and 4 all need a + markdown-section model of the body (locate `## Progress Log`, enumerate the AC + list, slice a named section). Build it once — a small body-structure helper — + and route `log`/`ac`/`show --section` through it. This is the same + markdown-structure-aware muscle the rename/link-lint work grew + ([make-rename-cascade-dangler-lint-markdown-structure-aware](6fka8khkb3jv-make-rename-cascade-dangler-lint-markdown-structure-aware.md)). +- **Reuse `EditBody`.** `log`/`ac` are structural body rewrites — route them + through the existing `FS.EditBody` / surgical `yaml.Node` path so unknown + frontmatter, comments, key order, and the parse-before-write + + compare-and-swap discipline all survive (the same guarantees `task append` / + `task set --body` already carry). +- **`ac:{checked, total}`** in `task info` needs the same AC enumerator as + `task ac --list` — one parser, two consumers. + +## Related + +- Sequel to [agent-facing-cli-ergonomics-batch](6fbj87000anh-agent-facing-cli-ergonomics-batch.md). +- Epic [20-cli-ux-and-ergonomics](../epics/20-cli-ux-and-ergonomics.md). +- Terse-output steering: [steer-agents-to-the-terse-output-path-in-schema-and-guidance](6fes83r0137q-steer-agents-to-the-terse-output-path-in-schema-and-guidance.md). +- Touches `internal/cli/`, `internal/core/` (body-structure helper + `EditBody`), + `internal/cli/render/`, `docs/cli`, `README.md`. + +## Progress (2026-07-15) + +Shipped the **read-only cluster** (items 3, 4 + the `task info` metadata read) — +no writes, no convention forks: + +- **`task path `** — prints the absolute file path (`--json` wraps it as + `{schema_version,path}`). Replaces `find`/glob on the id-led filename. +- **`task info `** — token-cheap metadata: `{path,status,epic,ac:{checked, + total}}` (+ `id`/`slug`), no body. New `task_info` envelope (schema 1.26 → 1.27, + alongside a small `path` envelope). Path is absolute (`filepath.Abs`) so it + resolves from anywhere. +- **`task show --section ` / `--frontmatter-only`** — narrow the body to one + named section (heading substring match) or drop it entirely; reuses the existing + `task_show` envelope (body just shrinks). A missing section is a clean + `ErrNotFound` (exit 10). + +Shared engine: a pure, fence-aware body-structure helper in `internal/domain` +(`Section` + `CountAcceptanceCriteria`) — the same muscle `task ac`/`task log` +will reuse. README "agent use" now advertises the reads plus the already-shipped +`task set --body-file -` / `task new --body-file -` (the discoverability half). +Full suite + lint green; golden fixtures + CLI docs regenerated. + +**Remaining:** +- `task ac` (item 2) — the acceptance-criteria checkbox writes. Independent, + index-based per the spec, reuses the new AC enumerator. The natural fast-follow. + (These very checkboxes were flipped by hand — the exact gap `task ac` closes.) +- `task log` (item 1) — parked pending a decision on the canonical Progress-section + shape (single `## Progress Log` with dated bullets, as the agent's repo uses, vs + this repo's de-facto per-entry `## Progress (date)` headings). Needs the shape + settled before building, or `task log` would hardcode a format that fights the + existing corpus. + +## Progress (2026-07-15) — adversarial-review fixes + +An independent adversarial review found four issues in the read-only cluster; +each was verified against the code and the real ones fixed: + +- **CRLF (parser blind to `\r\n`)** — the ATX heading regex is anchored `[ \t]*$`, + so a trailing `\r` made every heading vanish (→ empty sections, 0/0 AC tally) on + an autocrlf checkout or a CRLF `--body-file`. Fixed by folding CRLF/CR→LF at + parse entry (`normalizeNewlines`). Proven end-to-end: a piped CRLF body now + reports the right tally. +- **Nested code fences** — the old `inFence = !inFence` toggle closed on a shorter + inner fence or an info-stringed line (` ```go `), leaking fenced `##`/`- [ ]` + into structure scanning. Replaced with a CommonMark-ish `fenceScanner` that + tracks the opening fence's char + length and closes only on a same-or-longer + bare fence of the same char. +- **Golden coverage** — `task info`/`task path` weren't in the byte-golden harness + because their output carries an absolute path (the machine checkout root). Added + a path-redactor (`` placeholder) so both are now pinned portably. +- **Trailing blank line** — `--frontmatter-only` left a stray blank line under the + fields (`\n%s` with an empty body); guarded in `TaskShowHuman`. + +Regression tests added (CRLF heading + AC tally, nested-fence non-leak, +frontmatter-only no-trailing-blank). Two review recommendations were **declined +with reason**: "Suite + lint green" stays checked (literally true — the suite +passes), and the `schema`-advertises claim was **reworded** rather than unchecked, +to name the surfaces that actually advertise the `--body-file` variants (`--help`, +README, `docs/cli`) — the `schema` command is the data contract, not a command +index. Full suite + lint green; golden fixtures + comment map + CLI docs +regenerated. + +## Progress (2026-07-16) — epic/audit parity + Q1 cleanups + +Extended the read-only cluster to epics/audits (high-value parity) and landed the +two Q1 follow-ups: + +- **`epic path` + `audit path`** — the file locator, **parse-free** + (`Svc.EpicPath`/`AuditPath` over the store's existing resolvers). +- **`audit info` (+`--json`)** — token-cheap audit metadata: `{path, bucket, + findings:{total, open, in_progress, done, dropped}}` — the audit analogue of the + AC tally, read straight off the tally `parseAudit` already populates. New + `audit_info` envelope (schema 1.27 → 1.28). **Skipped `epic info`** (would + duplicate `epic list -c`). +- **`--section` / `--frontmatter-only` on `epic show` + `audit show`** — the same + body-scope flags as `task show`, now via a shared `narrowBody` + + `addBodyScopeFlags` helper (three shows, one implementation). Highest value on + audits (long bodies: `audit show --section findings`). + +Q1 cleanups: +- **Shared field-writer** — `render.fieldPrinter` now backs the metadata blocks of + `task show`, `task info`, and `audit info` (dropped the duplicated closures). +- **Parse-free `task path`** — `task path` (and epic/audit path) resolve WITHOUT + parsing, so they work on a file with broken frontmatter — exactly when you need + the path to open and fix it. Regression-tested (path resolves a broken file; + `task show` still fails on it). + +Tests: epic path / show --frontmatter-only / section-not-found; audit path / info +tally / show --section; parse-free broken-file path. Golden: `audit_info_json` +(path-redacted). docgen + comment map + README updated. Full suite + lint green. + +## Progress (2026-07-16) — adversarial-review round 2 (epic/audit diff) + +External review verdict was **GO with caveats**; verified each finding and fixed the +real ones: + +- **[major → hygiene] golden portability** — the path redactor matched only the raw + (single-backslash) root, so path-bearing goldens (`task/audit info`, `*/path`) would + fail on Windows (where `json.Marshal` escapes separators as `\\`). Made the redactor + fold `\\`→`/` and match the forward-slash root, so goldens are one form on every + platform. (CI is Ubuntu-only, so this was latent, not a live break — fixed anyway.) +- **[minor] TTY path truncation** — the shared `fieldPrinter` had added terminal-width + truncation to `task info`/`audit info`, cutting off the very path you run the command + to copy. Gave `fieldPrinter` a `fit` flag: browse views (`* show`) still truncate long + values; the `info` reads print full. Render-level regression tests at width 40. +- **[minor] epic/audit `--frontmatter-only` trailing blank line** — `EpicShowHuman`/ + `AuditShowHuman` printed `\n%s` unconditionally (the same nit fixed for tasks last + round, not carried over). Guarded both, plus the CLI-side `RenderBody` call. Asserted. +- **[nit] golden symmetry** — added `epic_path_json` / `audit_path_json` cases (task + path already had one). + +Reviewer PASSED lenses 1–3, 5, 6, 8 (narrowing-under-`--json`, parse-free resolution, +tally trust, port churn, section semantics, completion claims) and un-checked no boxes. +Full suite + lint green; goldens + docs regenerated. diff --git a/planning/tasks/6fpfcecfygt7-clearer-error-and-walk-up-discovery-when-anchored-outside-the-planning-tree.md b/planning/tasks/6fpfcecfygt7-clearer-error-and-walk-up-discovery-when-anchored-outside-the-planning-tree.md new file mode 100644 index 0000000..963a6d3 --- /dev/null +++ b/planning/tasks/6fpfcecfygt7-clearer-error-and-walk-up-discovery-when-anchored-outside-the-planning-tree.md @@ -0,0 +1,56 @@ +--- +schema: 1 +id: 6fpfcecfygt7 +status: ready-to-start +epic: 23-point-an-impl-repo-at-an-external-planning-repo +description: "" +effort: Unknown +tier: 3 +priority: medium +autonomy_level: 3 +tags: [cli, config, discovery, agents] +created: "2026-07-15" +--- +> ⚠️ **Externally proposed — filed 2026-07-15** from an agent dogfooding +> session. The config side of decoupled planning is done (this epic shipped +> `planning_repo`; `desirelines-planning` moved its entities under `planning/` +> in [isolate-desirelines-planning-entities-under-a-dedicated-planning-directory](6fjvdf9t848k-isolate-desirelines-planning-entities-under-a-dedicated-planning-directory.md)). +> What remains is the *ergonomic* when the tool is run from the wrong place. + +## Objective + +Running `tskflwctl` from an impl repo (e.g. `../desirelines`) failed with +`planning_repo … has no tasks/`, because the target's entities live under +`planning/tasks/`, not `tasks/` at the root. Two friction points fall out: + +1. **The error is opaque.** `has no tasks/` doesn't say what layout was + expected, that `taskflow_root = "planning"` (or a `planning/` subdir) is the + fix, or that `-C ` re-anchors. An agent can't self-correct from it. + Make the error name the resolved path it checked, the expected layout, and + the `-C` / `taskflow_root` remedies. +2. **No walk-up discovery (stretch).** The tool anchors to the physical + cwd/root, so from a subdir of either repo it can't find the planning tree. + Walking up to find the planning repo (or the `planning_repo` pointer) from + anywhere in either tree would remove a whole class of wrong-cwd errors — and + would defuse the persistent-shell `cd`-leak footgun the agent hit. + **Design tension:** the tool deliberately anchors to physical paths + (discovery is path-based everywhere — see epic 24 / the remote-backends + research), and walk-up must never silently pick the *wrong* tree. Scope it + carefully, or land the clearer error first and treat walk-up as a follow-on. + +## Acceptance criteria + +- [ ] The "no tasks/" failure names the path it checked, the expected layout, + and the `-C` / `taskflow_root` fixes — legible to an agent. +- [ ] Decide walk-up discovery: either implement bounded upward search that + refuses ambiguous matches, or explicitly record it as out of scope with + the path-anchoring rationale. +- [ ] Suite + lint green; docs / error copy regenerated as needed. + +## Related + +- Epic [23-point-an-impl-repo-at-an-external-planning-repo](../epics/23-point-an-impl-repo-at-an-external-planning-repo.md). +- Discovery/config cousins: + [discovery-honors-and-validates-planning-repo-out-of-tree](6fes83r010vs-discovery-honors-and-validates-planning-repo-out-of-tree.md), + [config-robustness-symlink-safe-discovery-and-toml-escapes](6fes83r00ztg-config-robustness-symlink-safe-discovery-and-toml-escapes.md). +- Touches discovery/config in `internal/` + the CLI error copy. From 6e15ed1561ec157599507d05b9d651288a24671d Mon Sep 17 00:00:00 2001 From: Andy Eschbacher Date: Thu, 16 Jul 2026 07:44:14 -0400 Subject: [PATCH 2/4] feat: better guards --- README.md | 1 + docs/cli/tskflwctl_task.md | 1 + docs/cli/tskflwctl_task_ac.md | 47 ++++ go.mod | 2 +- internal/cli/integration_golden_test.go | 1 + internal/cli/render/render.go | 23 ++ internal/cli/task.go | 74 ++++++ internal/cli/task_ac_test.go | 173 ++++++++++++++ .../golden/audit_findings_json.golden | 2 +- .../golden/audit_findings_open_json.golden | 2 +- .../testdata/golden/audit_info_json.golden | 2 +- .../testdata/golden/audit_path_json.golden | 2 +- .../cli/testdata/golden/board_json.golden | 2 +- .../cli/testdata/golden/epic_list_json.golden | 2 +- .../cli/testdata/golden/epic_path_json.golden | 2 +- .../cli/testdata/golden/epic_show_json.golden | 2 +- internal/cli/testdata/golden/lint_json.golden | 2 +- .../cli/testdata/golden/schema_json.golden | 2 +- .../testdata/golden/schema_jsonschema.golden | 54 ++++- .../testdata/golden/schema_task_json.golden | 2 +- .../cli/testdata/golden/status_json.golden | 2 +- .../golden/task_acceptance_json.golden | 1 + .../cli/testdata/golden/task_info_json.golden | 2 +- .../cli/testdata/golden/task_list_json.golden | 2 +- .../cli/testdata/golden/task_path_json.golden | 2 +- .../cli/testdata/golden/task_show_json.golden | 2 +- .../testdata/golden/template_list_json.golden | 2 +- .../golden/template_show_security_json.golden | 2 +- .../planning/tasks/6fjangd7kvh0-alpha-task.md | 5 + internal/core/service.go | 8 +- internal/core/service_epic_test.go | 11 + internal/core/service_task.go | 31 +++ internal/core/store.go | 12 + internal/core/usecases_test.go | 27 +++ internal/domain/body.go | 217 ++++++++++++++++-- internal/domain/body_test.go | 195 ++++++++++++++++ internal/store/fsstore.go | 14 ++ internal/store/fsstore_test.go | 19 ++ internal/wire/dto.go | 18 ++ internal/wire/envelopes.go | 15 ++ internal/wire/envelopes_test.go | 3 + internal/wire/schema_comments.json | 3 + internal/wire/wire.go | 5 +- ...-aware-body-mutation-and-metadata-reads.md | 52 ++++- 44 files changed, 1009 insertions(+), 39 deletions(-) create mode 100644 docs/cli/tskflwctl_task_ac.md create mode 100644 internal/cli/task_ac_test.go create mode 100644 internal/cli/testdata/golden/task_acceptance_json.golden diff --git a/README.md b/README.md index 48648b9..a738956 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,7 @@ tskflwctl task set --priority high --tags a,b tskflwctl task edit # open the whole file in $EDITOR (human; re-validated on save) echo "## Findings" | tskflwctl task append --body-file - # add a section (agent; atomic) tskflwctl task set --body-file notes.md # replace the body (agent; its own call) +tskflwctl task ac # numbered acceptance criteria; --check/--uncheck to flip one tskflwctl task start|next|ready|complete|defer|deprecate ... # defer takes --until tskflwctl task defer --until 2026-09-01 # snooze (revisit_at); on a TTY, prompts for the date tskflwctl audit close|reopen|defer ... diff --git a/docs/cli/tskflwctl_task.md b/docs/cli/tskflwctl_task.md index 4f6baed..4b23b9c 100644 --- a/docs/cli/tskflwctl_task.md +++ b/docs/cli/tskflwctl_task.md @@ -25,6 +25,7 @@ Work with tasks ### SEE ALSO * [tskflwctl](tskflwctl.md) - Local-first planning CLI (tasks, epics, audits) over markdown +* [tskflwctl task ac](tskflwctl_task_ac.md) - List a task's acceptance criteria, or check/uncheck one by index * [tskflwctl task append](tskflwctl_task_append.md) - Append a section to a task's body (atomic; agent-facing) * [tskflwctl task complete](tskflwctl_task_complete.md) - Move task(s) to completed * [tskflwctl task defer](tskflwctl_task_defer.md) - Move task(s) to deferred (optionally with a revisit date) diff --git a/docs/cli/tskflwctl_task_ac.md b/docs/cli/tskflwctl_task_ac.md new file mode 100644 index 0000000..3b45942 --- /dev/null +++ b/docs/cli/tskflwctl_task_ac.md @@ -0,0 +1,47 @@ +## tskflwctl task ac + +List a task's acceptance criteria, or check/uncheck one by index + +### Synopsis + +List a task's acceptance criteria — the checkboxes under its `## Acceptance criteria` section — or flip one by 1-based index. Run with no flags (or --list) to number them, then --check / --uncheck to tick or clear one. Matching is index-based, not substring, for robustness. A flip rewrites only that one checkbox (the rest of the file is preserved), is atomic, and is idempotent — flipping to the current state writes nothing. Checkboxes in fenced code blocks are ignored, and a missing section or out-of-range index is a validation error (exit 11). + +``` +tskflwctl task ac [flags] +``` + +### Examples + +``` + tskflwctl task ac add-retry-backoff # numbered list + tskflwctl task ac add-retry-backoff --check 3 # tick criterion 3 + tskflwctl task ac add-retry-backoff --uncheck 3 +``` + +### Options + +``` + --check int check the criterion at this 1-based index + -h, --help help for ac + --list list the acceptance criteria (the default) + --uncheck int uncheck the criterion at this 1-based index +``` + +### Options inherited from parent commands + +``` + -C, --chdir string anchor to the planning repo at this path + --color string colorize output: auto|always|never (default "auto") + --dry-run preview the mutation without writing (validation still runs) + --json machine-readable JSON output + --no-color disable colored output (alias for --color=never) + --no-input never prompt; missing required input is an error (for scripts/agents; also TSKFLW_NO_INPUT) + --no-pager do not pipe long human output through a pager + --paginate page long human output through $PAGER (on a TTY), even if disabled in config + --theme string color theme name (overrides TSKFLW_THEME and [theme].name in config) +``` + +### SEE ALSO + +* [tskflwctl task](tskflwctl_task.md) - Work with tasks + diff --git a/go.mod b/go.mod index 0b1be68..feeffb8 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/andy-esch/taskflow -go 1.25.8 +go 1.25.12 require ( charm.land/bubbles/v2 v2.1.0 diff --git a/internal/cli/integration_golden_test.go b/internal/cli/integration_golden_test.go index 4f60383..d5d787c 100644 --- a/internal/cli/integration_golden_test.go +++ b/internal/cli/integration_golden_test.go @@ -50,6 +50,7 @@ func TestGolden_MachineContract(t *testing.T) { {"task_list_csv", []string{"-C", fixtureRepo, "task", "list", "--all", "-o", "csv"}, nil}, {"task_list_name", []string{"-C", fixtureRepo, "task", "list", "--all", "-o", "name"}, nil}, {"task_show_json", []string{"-C", fixtureRepo, "task", "show", "alpha-task", "--json"}, nil}, + {"task_acceptance_json", []string{"-C", fixtureRepo, "task", "ac", "alpha-task", "--json"}, nil}, // task info / task path emit an absolute file path → redact the fixture root // so the committed golden is portable (pins schema_version + shape + tally). {"task_info_json", []string{"-C", fixtureRepo, "task", "info", "alpha-task", "--json"}, redact}, diff --git a/internal/cli/render/render.go b/internal/cli/render/render.go index ef0dff5..14f7d07 100644 --- a/internal/cli/render/render.go +++ b/internal/cli/render/render.go @@ -153,6 +153,29 @@ func AuditInfoHuman(w io.Writer, st Style, a domain.Audit, path string) { field("path", path) } +// AcceptanceJSON writes `task ac --list --json`: the task's acceptance criteria. +func AcceptanceJSON(w io.Writer, slug string, cs []domain.Criterion) error { + return wire.EncodeJSON(w, wire.ToAcceptanceEnvelope(slug, cs)) +} + +// AcceptanceHuman prints the numbered acceptance checklist ("[x] 1. text"), the +// list an agent then flips by index. Empty prints a dim note. +func AcceptanceHuman(w io.Writer, st Style, cs []domain.Criterion) { + if len(cs) == 0 { + fmt.Fprintln(w, st.Dim("no acceptance criteria")) + return + } + for _, c := range cs { + box := "[ ]" + mark := st.Dim(box) + if c.Checked { + box = "[x]" + mark = st.Green(box) + } + fmt.Fprintf(w, "%s %s %s\n", mark, st.Dim(fmt.Sprintf("%2d.", c.Index)), c.Text) + } +} + // PathJSON writes just the resolved absolute path (`task/epic/audit path --json`). func PathJSON(w io.Writer, path string) error { return wire.EncodeJSON(w, wire.ToPathEnvelope(path)) diff --git a/internal/cli/task.go b/internal/cli/task.go index c9e5c0a..344bf2b 100644 --- a/internal/cli/task.go +++ b/internal/cli/task.go @@ -62,6 +62,7 @@ func newTaskCmd(app *App) *cobra.Command { newTaskShowCmd(app), newTaskInfoCmd(app), newTaskPathCmd(app), + newTaskAcCmd(app), newTaskSetCmd(app), newTaskEditCmd(app), newTaskAppendCmd(app), @@ -349,6 +350,79 @@ func emitPath(app *App, path string) error { return nil } +// newTaskAcCmd lists a task's acceptance criteria, or flips one by index — the CLI +// for close-out edits that otherwise force a hand-edit of `- [ ]` → `- [x]`. +// Index-based (`--list` to number them, then `--check 3`) is the robust form; +// substring matching is deliberately not offered. A flip goes through the atomic, +// frontmatter-preserving body-replace path and returns the task_mutation envelope. +func newTaskAcCmd(app *App) *cobra.Command { + var check, uncheck int + var list bool + cmd := &cobra.Command{ + Use: "ac ", + Short: "List a task's acceptance criteria, or check/uncheck one by index", + Long: "List a task's acceptance criteria — the checkboxes under its " + + "`## Acceptance criteria` section — or flip one by 1-based index. Run with no " + + "flags (or --list) to number them, then --check / --uncheck to tick or " + + "clear one. Matching is index-based, not substring, for robustness. A flip " + + "rewrites only that one checkbox (the rest of the file is preserved), is atomic, " + + "and is idempotent — flipping to the current state writes nothing. Checkboxes in " + + "fenced code blocks are ignored, and a missing section or out-of-range index is a " + + "validation error (exit 11).", + Example: " tskflwctl task ac add-retry-backoff # numbered list\n tskflwctl task ac add-retry-backoff --check 3 # tick criterion 3\n tskflwctl task ac add-retry-backoff --uncheck 3", + Args: cobra.MaximumNArgs(1), + Annotations: map[string]string{"safety": "mutating"}, // --check/--uncheck write; --list reads + ValidArgsFunction: app.completeTaskSlugs, + RunE: func(c *cobra.Command, args []string) error { + slug, err := app.resolveOne(args, "specify a task", "no tasks available", "Task", app.taskOptions) + if err != nil { + return err + } + // No --check/--uncheck → the list view (the default; --list is explicit). + if !c.Flags().Changed("check") && !c.Flags().Changed("uncheck") { + canon, cs, err := app.Svc.AcceptanceCriteria(slug) + if err != nil { + return err + } + if app.JSON { + return render.AcceptanceJSON(app.Out, canon, cs) + } + render.AcceptanceHuman(app.Out, app.Style, cs) + return nil + } + checked := c.Flags().Changed("check") + idx := check + if !checked { + idx = uncheck + } + task, body, changed, err := app.Svc.SetAcceptanceCriterion(slug, idx, checked, app.DryRun) + if err != nil { + return err + } + if !changed && !app.JSON { // already in the target state — say so, no write + state := "checked" + if !checked { + state = "unchecked" + } + fmt.Fprintf(app.Out, "%s criterion %d is already %s\n", app.Style.Dim("•"), idx, state) + return nil + } + verb, dryVerb := "checked", "would check" + if !checked { + verb, dryVerb = "unchecked", "would uncheck" + } + return reportTaskMutation(app, task, body, verb, dryVerb) + }, + } + cmd.Flags().BoolVar(&list, "list", false, "list the acceptance criteria (the default)") + cmd.Flags().IntVar(&check, "check", 0, "check the criterion at this 1-based index") + cmd.Flags().IntVar(&uncheck, "uncheck", 0, "uncheck the criterion at this 1-based index") + cmd.MarkFlagsMutuallyExclusive("check", "uncheck") + cmd.MarkFlagsMutuallyExclusive("list", "check") + cmd.MarkFlagsMutuallyExclusive("list", "uncheck") + return cmd +} + // absPath makes a store path absolute so `task path`/`task info` emit a path that // resolves from anywhere, regardless of how the planning root was configured // (relative config root, -C, etc.). A failure to absolutize (never expected for a diff --git a/internal/cli/task_ac_test.go b/internal/cli/task_ac_test.go new file mode 100644 index 0000000..4b1bb00 --- /dev/null +++ b/internal/cli/task_ac_test.go @@ -0,0 +1,173 @@ +package cli + +import ( + "bytes" + "encoding/json" + "errors" + "os" + "strings" + "testing" + + "github.com/andy-esch/taskflow/internal/domain" +) + +const acTestBody = "# Alpha\n\n## Acceptance criteria\n\n- [ ] first\n- [x] second\n- [ ] third\n" + +// setupRepoWithAC gives alpha a 3-item acceptance section (2 unchecked, 1 checked). +func setupRepoWithAC(t *testing.T) string { + t.Helper() + root := setupRepo(t) + runRoot(t, "-C", root, "task", "set", "alpha", "--body", acTestBody) + return root +} + +func TestTaskAc_List(t *testing.T) { + root := setupRepoWithAC(t) + out := runRoot(t, "-C", root, "task", "ac", "alpha") + for _, want := range []string{"1.", "first", "2.", "second", "3.", "third", "[x]", "[ ]"} { + if !strings.Contains(out, want) { + t.Errorf("ac list missing %q:\n%s", want, out) + } + } +} + +func TestTaskAc_List_JSON(t *testing.T) { + root := setupRepoWithAC(t) + out := runRoot(t, "-C", root, "--json", "task", "ac", "alpha") + var env struct { + SchemaVersion string `json:"schema_version"` + Slug string `json:"slug"` + Acceptance []struct { + Index int `json:"index"` + Checked bool `json:"checked"` + Text string `json:"text"` + } `json:"acceptance"` + } + if err := json.Unmarshal([]byte(out), &env); err != nil { + t.Fatalf("ac --json not parseable: %v\n%s", err, out) + } + if env.SchemaVersion == "" || env.Slug != "alpha" || len(env.Acceptance) != 3 { + t.Fatalf("ac --json wrong shape:\n%s", out) + } + if env.Acceptance[0].Checked || !env.Acceptance[1].Checked || env.Acceptance[1].Text != "second" { + t.Errorf("ac --json criteria wrong:\n%s", out) + } +} + +// Listing a task with no acceptance section succeeds with an empty list (distinct +// from a flip, which errors) — human says so, --json emits [] (never null). +func TestTaskAc_List_NoSection(t *testing.T) { + root := setupRepo(t) // alpha's body is just "# Alpha" + if out := runRoot(t, "-C", root, "task", "ac", "alpha"); !strings.Contains(out, "no acceptance criteria") { + t.Errorf("list of a task with no AC section should say so:\n%s", out) + } + out := runRoot(t, "-C", root, "--json", "task", "ac", "alpha") + if !strings.Contains(out, `"acceptance":[]`) { + t.Errorf("empty acceptance must marshal to [] (not null):\n%s", out) + } +} + +func TestTaskAc_Check_FlipsFile(t *testing.T) { + root := setupRepoWithAC(t) + runRoot(t, "-C", root, "task", "ac", "alpha", "--check", "1") + got := readFile(t, alphaPath(root)) + if !strings.Contains(got, "- [x] first") { + t.Errorf("--check 1 should tick criterion 1:\n%s", got) + } + if !strings.Contains(got, "status: ready-to-start") { + t.Errorf("frontmatter must be preserved by the flip:\n%s", got) + } + if !strings.Contains(got, "- [x] second") || !strings.Contains(got, "- [ ] third") { + t.Errorf("--check 1 must not disturb other criteria:\n%s", got) + } +} + +func TestTaskAc_Uncheck_FlipsFile(t *testing.T) { + root := setupRepoWithAC(t) + runRoot(t, "-C", root, "task", "ac", "alpha", "--uncheck", "2") + if got := readFile(t, alphaPath(root)); !strings.Contains(got, "- [ ] second") { + t.Errorf("--uncheck 2 should clear criterion 2:\n%s", got) + } +} + +func TestTaskAc_Check_OutOfRange(t *testing.T) { + root := setupRepoWithAC(t) + if _, err := runRootRC(t, "-C", root, "task", "ac", "alpha", "--check", "99"); !errors.Is(err, domain.ErrValidation) { + t.Errorf("out-of-range index should wrap ErrValidation (exit 11), got %v", err) + } +} + +func TestTaskAc_Check_NoSection(t *testing.T) { + root := setupRepo(t) // alpha's body is just "# Alpha" — no acceptance section + if _, err := runRootRC(t, "-C", root, "task", "ac", "alpha", "--check", "1"); !errors.Is(err, domain.ErrValidation) { + t.Errorf("no AC section should wrap ErrValidation, got %v", err) + } +} + +func TestTaskAc_Check_Idempotent_NoWrite(t *testing.T) { + root := setupRepoWithAC(t) + before, _ := os.ReadFile(alphaPath(root)) + out := runRoot(t, "-C", root, "task", "ac", "alpha", "--check", "2") // #2 is already checked + after, _ := os.ReadFile(alphaPath(root)) + if !bytes.Equal(before, after) { + t.Error("checking an already-checked criterion must not write") + } + if !strings.Contains(out, "already checked") { + t.Errorf("expected an 'already checked' note:\n%s", out) + } +} + +func TestTaskAc_Check_DryRun_NoWrite(t *testing.T) { + root := setupRepoWithAC(t) + before, _ := os.ReadFile(alphaPath(root)) + runRoot(t, "-C", root, "--dry-run", "task", "ac", "alpha", "--check", "1") + after, _ := os.ReadFile(alphaPath(root)) + if !bytes.Equal(before, after) { + t.Error("--dry-run --check must not write") + } +} + +func TestTaskAc_CheckUncheck_Exclusive(t *testing.T) { + root := setupRepoWithAC(t) + if _, err := runRootRC(t, "-C", root, "task", "ac", "alpha", "--check", "1", "--uncheck", "2"); err == nil { + t.Fatal("--check and --uncheck should be mutually exclusive") + } +} + +// A flip returns the task_mutation envelope (it edits the body), echoing the result. +func TestTaskAc_Check_JSON_MutationEnvelope(t *testing.T) { + root := setupRepoWithAC(t) + out := runRoot(t, "-C", root, "--json", "task", "ac", "alpha", "--check", "1") + var env mutationEnv // defined in body_test.go + if err := json.Unmarshal([]byte(out), &env); err != nil { + t.Fatalf("ac --check --json is not a task_mutation envelope: %v\n%s", err, out) + } + if env.Task.Slug != "alpha" || !strings.Contains(env.Body, "- [x] first") { + t.Errorf("ac --check --json should echo the flipped body:\n%s", out) + } +} + +// The acceptance-criteria lint guard flows end-to-end through `lint` (exercising the +// body-carrying scan): a task whose acceptance section has a botched checkbox is +// flagged, so the silent under-count is caught rather than trusted. +func TestLint_FlagsMalformedAcceptance(t *testing.T) { + root := setupRepoWithAC(t) + // Replace with a body that has a botched checkbox in the acceptance section. + runRoot(t, "-C", root, "task", "set", "alpha", "--body", + "# Alpha\n\n## Acceptance criteria\n\n- [x] ok\n- [] botched\n") + // lint exits non-zero when it finds issues, so read via runRootRC and inspect the + // (still-emitted) JSON rather than fataling on the exit code. + out, _ := runRootRC(t, "-C", root, "--json", "lint") + if !strings.Contains(out, "malformed acceptance checkbox") { + t.Errorf("lint --json should surface the malformed acceptance checkbox:\n%s", out) + } +} + +func readFile(t *testing.T, path string) string { + t.Helper() + b, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + return string(b) +} diff --git a/internal/cli/testdata/golden/audit_findings_json.golden b/internal/cli/testdata/golden/audit_findings_json.golden index 8afbb70..49a3da4 100644 --- a/internal/cli/testdata/golden/audit_findings_json.golden +++ b/internal/cli/testdata/golden/audit_findings_json.golden @@ -1 +1 @@ -{"schema_version":"1.28","findings":[{"audit":"2026-01-02-fixture-area","bucket":"open","code":"S1","title":"Tighten the fixture gateway","status":"open","component":"fixturepipe","effort":"S","urgency":"soon"},{"audit":"2026-01-02-fixture-area","bucket":"open","code":"H1","title":"Fix the fixture bypass","status":"fixed","component":"auth","effort":"M","urgency":"acute"}]} +{"schema_version":"1.29","findings":[{"audit":"2026-01-02-fixture-area","bucket":"open","code":"S1","title":"Tighten the fixture gateway","status":"open","component":"fixturepipe","effort":"S","urgency":"soon"},{"audit":"2026-01-02-fixture-area","bucket":"open","code":"H1","title":"Fix the fixture bypass","status":"fixed","component":"auth","effort":"M","urgency":"acute"}]} diff --git a/internal/cli/testdata/golden/audit_findings_open_json.golden b/internal/cli/testdata/golden/audit_findings_open_json.golden index 0bd8a2e..fe8233c 100644 --- a/internal/cli/testdata/golden/audit_findings_open_json.golden +++ b/internal/cli/testdata/golden/audit_findings_open_json.golden @@ -1 +1 @@ -{"schema_version":"1.28","findings":[{"audit":"2026-01-02-fixture-area","bucket":"open","code":"S1","title":"Tighten the fixture gateway","status":"open","component":"fixturepipe","effort":"S","urgency":"soon"}]} +{"schema_version":"1.29","findings":[{"audit":"2026-01-02-fixture-area","bucket":"open","code":"S1","title":"Tighten the fixture gateway","status":"open","component":"fixturepipe","effort":"S","urgency":"soon"}]} diff --git a/internal/cli/testdata/golden/audit_info_json.golden b/internal/cli/testdata/golden/audit_info_json.golden index 3a15426..f8f81bc 100644 --- a/internal/cli/testdata/golden/audit_info_json.golden +++ b/internal/cli/testdata/golden/audit_info_json.golden @@ -1 +1 @@ -{"schema_version":"1.28","audit_info":{"id":"6fjangd7kvh3","slug":"2026-01-02-fixture-area","bucket":"open","path":"/audits/6fjangd7kvh3-2026-01-02-fixture-area.md","findings":{"total":2,"open":1,"in_progress":0,"done":1,"dropped":0}}} +{"schema_version":"1.29","audit_info":{"id":"6fjangd7kvh3","slug":"2026-01-02-fixture-area","bucket":"open","path":"/audits/6fjangd7kvh3-2026-01-02-fixture-area.md","findings":{"total":2,"open":1,"in_progress":0,"done":1,"dropped":0}}} diff --git a/internal/cli/testdata/golden/audit_path_json.golden b/internal/cli/testdata/golden/audit_path_json.golden index 62265a2..9761a9b 100644 --- a/internal/cli/testdata/golden/audit_path_json.golden +++ b/internal/cli/testdata/golden/audit_path_json.golden @@ -1 +1 @@ -{"schema_version":"1.28","path":"/audits/6fjangd7kvh3-2026-01-02-fixture-area.md"} +{"schema_version":"1.29","path":"/audits/6fjangd7kvh3-2026-01-02-fixture-area.md"} diff --git a/internal/cli/testdata/golden/board_json.golden b/internal/cli/testdata/golden/board_json.golden index 6c725e3..676c4b4 100644 --- a/internal/cli/testdata/golden/board_json.golden +++ b/internal/cli/testdata/golden/board_json.golden @@ -1 +1 @@ -{"schema_version":"1.28","columns":[{"status":"next-up","tasks":[]},{"status":"ready-to-start","tasks":[{"id":"6fjangd7kvh0","slug":"alpha-task","status":"ready-to-start","epic":"01-fixture-epic","description":"A fully specified ready-to-start task for golden snapshots","effort":"S","tier":2,"priority":"high","autonomy_level":3,"created":"2026-01-02","updated_at":"2026-01-03","tags":["cli","testing"]}]},{"status":"in-progress","tasks":[{"id":"6fjangd7kvh1","slug":"beta-task","status":"in-progress","epic":"01-fixture-epic","description":"An in-progress fixture task","effort":"M","tier":3,"priority":"medium","autonomy_level":2,"created":"2026-01-02","updated_at":"2026-01-03","tags":["cli"]}]}]} +{"schema_version":"1.29","columns":[{"status":"next-up","tasks":[]},{"status":"ready-to-start","tasks":[{"id":"6fjangd7kvh0","slug":"alpha-task","status":"ready-to-start","epic":"01-fixture-epic","description":"A fully specified ready-to-start task for golden snapshots","effort":"S","tier":2,"priority":"high","autonomy_level":3,"created":"2026-01-02","updated_at":"2026-01-03","tags":["cli","testing"]}]},{"status":"in-progress","tasks":[{"id":"6fjangd7kvh1","slug":"beta-task","status":"in-progress","epic":"01-fixture-epic","description":"An in-progress fixture task","effort":"M","tier":3,"priority":"medium","autonomy_level":2,"created":"2026-01-02","updated_at":"2026-01-03","tags":["cli"]}]}]} diff --git a/internal/cli/testdata/golden/epic_list_json.golden b/internal/cli/testdata/golden/epic_list_json.golden index dbdc2d9..c4060e9 100644 --- a/internal/cli/testdata/golden/epic_list_json.golden +++ b/internal/cli/testdata/golden/epic_list_json.golden @@ -1 +1 @@ -{"schema_version":"1.28","epics":[{"id":"01-fixture-epic","status":"active","description":"A fixture epic for golden snapshots","priority":"high","created":"2026-01-01","tags":["fixture"],"total":3,"done":1,"open":2,"percent":33,"deprecated":0,"liveness":"working"}]} +{"schema_version":"1.29","epics":[{"id":"01-fixture-epic","status":"active","description":"A fixture epic for golden snapshots","priority":"high","created":"2026-01-01","tags":["fixture"],"total":3,"done":1,"open":2,"percent":33,"deprecated":0,"liveness":"working"}]} diff --git a/internal/cli/testdata/golden/epic_path_json.golden b/internal/cli/testdata/golden/epic_path_json.golden index a02a012..3835b53 100644 --- a/internal/cli/testdata/golden/epic_path_json.golden +++ b/internal/cli/testdata/golden/epic_path_json.golden @@ -1 +1 @@ -{"schema_version":"1.28","path":"/epics/01-fixture-epic.md"} +{"schema_version":"1.29","path":"/epics/01-fixture-epic.md"} diff --git a/internal/cli/testdata/golden/epic_show_json.golden b/internal/cli/testdata/golden/epic_show_json.golden index 0dda97f..62c8500 100644 --- a/internal/cli/testdata/golden/epic_show_json.golden +++ b/internal/cli/testdata/golden/epic_show_json.golden @@ -1 +1 @@ -{"schema_version":"1.28","epic":{"id":"01-fixture-epic","status":"active","description":"A fixture epic for golden snapshots","priority":"high","created":"2026-01-01","tags":["fixture"]},"tasks":[{"id":"6fjangd7kvh0","slug":"alpha-task","status":"ready-to-start","epic":"01-fixture-epic","description":"A fully specified ready-to-start task for golden snapshots","effort":"S","tier":2,"priority":"high","autonomy_level":3,"created":"2026-01-02","updated_at":"2026-01-03","tags":["cli","testing"]},{"id":"6fjangd7kvh1","slug":"beta-task","status":"in-progress","epic":"01-fixture-epic","description":"An in-progress fixture task","effort":"M","tier":3,"priority":"medium","autonomy_level":2,"created":"2026-01-02","updated_at":"2026-01-03","tags":["cli"]},{"id":"6fjangd7kvh2","slug":"gamma-task","status":"completed","epic":"01-fixture-epic","description":"A completed fixture task","tier":1,"priority":"low","created":"2026-01-01","updated_at":"2026-01-02","tags":["docs"]}],"body":"# Fixture Epic\n\nThe epic that the fixture tasks roll up into.\n"} +{"schema_version":"1.29","epic":{"id":"01-fixture-epic","status":"active","description":"A fixture epic for golden snapshots","priority":"high","created":"2026-01-01","tags":["fixture"]},"tasks":[{"id":"6fjangd7kvh0","slug":"alpha-task","status":"ready-to-start","epic":"01-fixture-epic","description":"A fully specified ready-to-start task for golden snapshots","effort":"S","tier":2,"priority":"high","autonomy_level":3,"created":"2026-01-02","updated_at":"2026-01-03","tags":["cli","testing"]},{"id":"6fjangd7kvh1","slug":"beta-task","status":"in-progress","epic":"01-fixture-epic","description":"An in-progress fixture task","effort":"M","tier":3,"priority":"medium","autonomy_level":2,"created":"2026-01-02","updated_at":"2026-01-03","tags":["cli"]},{"id":"6fjangd7kvh2","slug":"gamma-task","status":"completed","epic":"01-fixture-epic","description":"A completed fixture task","tier":1,"priority":"low","created":"2026-01-01","updated_at":"2026-01-02","tags":["docs"]}],"body":"# Fixture Epic\n\nThe epic that the fixture tasks roll up into.\n"} diff --git a/internal/cli/testdata/golden/lint_json.golden b/internal/cli/testdata/golden/lint_json.golden index c5c7aae..ce15f03 100644 --- a/internal/cli/testdata/golden/lint_json.golden +++ b/internal/cli/testdata/golden/lint_json.golden @@ -1 +1 @@ -{"schema_version":"1.28","unreadable":[],"issues":[]} +{"schema_version":"1.29","unreadable":[],"issues":[]} diff --git a/internal/cli/testdata/golden/schema_json.golden b/internal/cli/testdata/golden/schema_json.golden index 0bbb393..33805c6 100644 --- a/internal/cli/testdata/golden/schema_json.golden +++ b/internal/cli/testdata/golden/schema_json.golden @@ -1 +1 @@ -{"schema_version":"1.28","statuses":[{"value":"next-up","active":true},{"value":"ready-to-start","active":true},{"value":"in-progress","active":true},{"value":"completed","active":false},{"value":"deprecated","active":false},{"value":"deferred","active":false}],"epic_statuses":["active","retired","deprecated"],"audit_buckets":["open","closed","deferred"],"finding_statuses":["deferred","fixed","in-progress","landed","open","superseded","wontfix"],"task_fields":[{"name":"audit_sources","type":"list"},{"name":"audited","type":"date"},{"name":"autonomy_level","type":"int"},{"name":"blocked_by","type":"list"},{"name":"blocks","type":"list"},{"name":"completed_at","type":"date"},{"name":"created","type":"date"},{"name":"deferred_at","type":"date"},{"name":"dependencies","type":"list"},{"name":"deprecated_at","type":"date"},{"name":"description","type":"string"},{"name":"effort","type":"string"},{"name":"epic","type":"string"},{"name":"priority","type":"string"},{"name":"projects","type":"list"},{"name":"related_tasks","type":"list"},{"name":"revisit_at","type":"date"},{"name":"started_at","type":"date"},{"name":"status","type":"string"},{"name":"tags","type":"list"},{"name":"tier","type":"int"},{"name":"updated_at","type":"date"}],"epic_fields":["created","description","priority","status","tags"],"exit_codes":[{"code":10,"name":"not-found"},{"code":11,"name":"validation"},{"code":13,"name":"ambiguous"},{"code":14,"name":"conflict"}],"kinds":["task","epic","audit"]} +{"schema_version":"1.29","statuses":[{"value":"next-up","active":true},{"value":"ready-to-start","active":true},{"value":"in-progress","active":true},{"value":"completed","active":false},{"value":"deprecated","active":false},{"value":"deferred","active":false}],"epic_statuses":["active","retired","deprecated"],"audit_buckets":["open","closed","deferred"],"finding_statuses":["deferred","fixed","in-progress","landed","open","superseded","wontfix"],"task_fields":[{"name":"audit_sources","type":"list"},{"name":"audited","type":"date"},{"name":"autonomy_level","type":"int"},{"name":"blocked_by","type":"list"},{"name":"blocks","type":"list"},{"name":"completed_at","type":"date"},{"name":"created","type":"date"},{"name":"deferred_at","type":"date"},{"name":"dependencies","type":"list"},{"name":"deprecated_at","type":"date"},{"name":"description","type":"string"},{"name":"effort","type":"string"},{"name":"epic","type":"string"},{"name":"priority","type":"string"},{"name":"projects","type":"list"},{"name":"related_tasks","type":"list"},{"name":"revisit_at","type":"date"},{"name":"started_at","type":"date"},{"name":"status","type":"string"},{"name":"tags","type":"list"},{"name":"tier","type":"int"},{"name":"updated_at","type":"date"}],"epic_fields":["created","description","priority","status","tags"],"exit_codes":[{"code":10,"name":"not-found"},{"code":11,"name":"validation"},{"code":13,"name":"ambiguous"},{"code":14,"name":"conflict"}],"kinds":["task","epic","audit"]} diff --git a/internal/cli/testdata/golden/schema_jsonschema.golden b/internal/cli/testdata/golden/schema_jsonschema.golden index 35c73ac..1ee8468 100644 --- a/internal/cli/testdata/golden/schema_jsonschema.golden +++ b/internal/cli/testdata/golden/schema_jsonschema.golden @@ -22,6 +22,30 @@ ], "description": "ACJSON is a task's acceptance-criteria checkbox tally (the `ac` field of `task info`): how many criteria are checked out of the total." }, + "AcceptanceEnvelope": { + "properties": { + "schema_version": { + "type": "string" + }, + "slug": { + "type": "string" + }, + "acceptance": { + "items": { + "$ref": "#/$defs/CriterionJSON" + }, + "type": "array" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "schema_version", + "slug", + "acceptance" + ], + "description": "AcceptanceEnvelope wraps `task ac --list --json` — a task's acceptance criteria with their checked state (the list an agent flips by index)." + }, "AuditInfoEnvelope": { "properties": { "schema_version": { @@ -318,6 +342,30 @@ ], "description": "CreatedItem is the created document inside CreatedEnvelope." }, + "CriterionJSON": { + "properties": { + "index": { + "type": "integer", + "description": "1-based position of the criterion in the acceptance section" + }, + "checked": { + "type": "boolean", + "description": "whether the checkbox is checked (- [x])" + }, + "text": { + "type": "string", + "description": "the criterion text — the first line after the checkbox" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "index", + "checked", + "text" + ], + "description": "CriterionJSON is one acceptance-criteria checkbox for `task ac --list --json` — the list an agent then flips by index with `task ac --check/--uncheck`." + }, "DoctorEnvelope": { "properties": { "schema_version": { @@ -1628,6 +1676,9 @@ "task_info": { "$ref": "#/$defs/TaskInfoEnvelope" }, + "acceptance": { + "$ref": "#/$defs/AcceptanceEnvelope" + }, "path": { "$ref": "#/$defs/PathEnvelope" }, @@ -1711,6 +1762,7 @@ "board", "task_show", "task_info", + "acceptance", "path", "task_mutation", "epic_mutation", @@ -1739,6 +1791,6 @@ ] } }, - "title": "tskflwctl --json output (schema_version 1.28)", + "title": "tskflwctl --json output (schema_version 1.29)", "description": "Each property of the root names a --json envelope and references its definition in $defs; validate a command's --json output against the matching definition." } diff --git a/internal/cli/testdata/golden/schema_task_json.golden b/internal/cli/testdata/golden/schema_task_json.golden index 4cf78b0..4de1911 100644 --- a/internal/cli/testdata/golden/schema_task_json.golden +++ b/internal/cli/testdata/golden/schema_task_json.golden @@ -1 +1 @@ -{"schema_version":"1.28","kind":"task","sections":["Objective","Acceptance criteria","Out of scope","Related"],"body_template":"\n# \u003ctitle\u003e\n\n## Objective\n\n\u003cwhy / what — one short paragraph\u003e\n\n## Acceptance criteria\n\n- [ ] \u003cobservable outcome\u003e\n\n## Out of scope\n\n- \u003cexplicitly excluded\u003e\n\n## Related\n\n- Epic [\u003cepic-id\u003e](../epics/\u003cepic-id\u003e.md)\n","fields":[{"name":"epic","type":"string","required":true,"description":"ID of the epic this task belongs to; must already exist.","example":"17-pm-go-cli"},{"name":"description","type":"string","required":false,"description":"One line summarizing the task (≤200 chars); required once next-up/in-progress.","example":"Add retry backoff to the Strava webhook"},{"name":"effort","type":"string","required":false,"description":"Rough size estimate (free-form).","example":"1-2 hours"},{"name":"tier","type":"int","required":false,"description":"Importance, 1 (highest) – 5 (lowest).","example":"2"},{"name":"priority","type":"string","required":false,"description":"One of: high | medium | low.","example":"medium"},{"name":"autonomy_level","type":"int","required":false,"description":"How autonomously this can be done, 1–5.","example":"3"},{"name":"tags","type":"list","required":true,"description":"At least one topical tag (required at creation).","example":"[cli, core]"}],"conventions":["status lives in frontmatter (authoritative) and is changed only via the lifecycle verbs (start/next/complete/…), which edit it in place — don't edit it directly.","description is a single line, ≤200 characters.","at least one tag is required at creation.","the filename slug is derived from the title; any title is accepted (colons, dashes, arrows, …) and the full title is kept as the body H1."],"templates":[{"kind":"task","name":"default","description":"Standard task scaffold: objective, acceptance criteria, out-of-scope, related epic."}]} +{"schema_version":"1.29","kind":"task","sections":["Objective","Acceptance criteria","Out of scope","Related"],"body_template":"\n# \u003ctitle\u003e\n\n## Objective\n\n\u003cwhy / what — one short paragraph\u003e\n\n## Acceptance criteria\n\n- [ ] \u003cobservable outcome\u003e\n\n## Out of scope\n\n- \u003cexplicitly excluded\u003e\n\n## Related\n\n- Epic [\u003cepic-id\u003e](../epics/\u003cepic-id\u003e.md)\n","fields":[{"name":"epic","type":"string","required":true,"description":"ID of the epic this task belongs to; must already exist.","example":"17-pm-go-cli"},{"name":"description","type":"string","required":false,"description":"One line summarizing the task (≤200 chars); required once next-up/in-progress.","example":"Add retry backoff to the Strava webhook"},{"name":"effort","type":"string","required":false,"description":"Rough size estimate (free-form).","example":"1-2 hours"},{"name":"tier","type":"int","required":false,"description":"Importance, 1 (highest) – 5 (lowest).","example":"2"},{"name":"priority","type":"string","required":false,"description":"One of: high | medium | low.","example":"medium"},{"name":"autonomy_level","type":"int","required":false,"description":"How autonomously this can be done, 1–5.","example":"3"},{"name":"tags","type":"list","required":true,"description":"At least one topical tag (required at creation).","example":"[cli, core]"}],"conventions":["status lives in frontmatter (authoritative) and is changed only via the lifecycle verbs (start/next/complete/…), which edit it in place — don't edit it directly.","description is a single line, ≤200 characters.","at least one tag is required at creation.","the filename slug is derived from the title; any title is accepted (colons, dashes, arrows, …) and the full title is kept as the body H1."],"templates":[{"kind":"task","name":"default","description":"Standard task scaffold: objective, acceptance criteria, out-of-scope, related epic."}]} diff --git a/internal/cli/testdata/golden/status_json.golden b/internal/cli/testdata/golden/status_json.golden index 0515cc9..b140f85 100644 --- a/internal/cli/testdata/golden/status_json.golden +++ b/internal/cli/testdata/golden/status_json.golden @@ -1 +1 @@ -{"schema_version":"1.28","counts":[{"status":"next-up","count":0},{"status":"ready-to-start","count":1},{"status":"in-progress","count":1},{"status":"completed","count":1},{"status":"deprecated","count":0},{"status":"deferred","count":0}],"in_progress":[{"id":"6fjangd7kvh1","slug":"beta-task","status":"in-progress","epic":"01-fixture-epic","description":"An in-progress fixture task","effort":"M","tier":3,"priority":"medium","autonomy_level":2,"created":"2026-01-02","updated_at":"2026-01-03","tags":["cli"]}],"epics":[{"id":"01-fixture-epic","status":"active","description":"A fixture epic for golden snapshots","priority":"high","created":"2026-01-01","tags":["fixture"],"total":3,"done":1,"open":2,"percent":33,"deprecated":0,"liveness":"working"}],"open_audits":[{"id":"6fjangd7kvh3","slug":"2026-01-02-fixture-area","bucket":"open","area":"fixture-area","date":"2026-01-02","findings":2,"open_findings":1,"in_progress_findings":0,"done_findings":1,"dropped_findings":0}],"findings":{"open":1,"in_progress":0,"by_urgency":[{"key":"soon","count":1}],"by_component":[{"key":"fixturepipe","count":1}]},"revisit_due":0,"bad_epic_status":0} +{"schema_version":"1.29","counts":[{"status":"next-up","count":0},{"status":"ready-to-start","count":1},{"status":"in-progress","count":1},{"status":"completed","count":1},{"status":"deprecated","count":0},{"status":"deferred","count":0}],"in_progress":[{"id":"6fjangd7kvh1","slug":"beta-task","status":"in-progress","epic":"01-fixture-epic","description":"An in-progress fixture task","effort":"M","tier":3,"priority":"medium","autonomy_level":2,"created":"2026-01-02","updated_at":"2026-01-03","tags":["cli"]}],"epics":[{"id":"01-fixture-epic","status":"active","description":"A fixture epic for golden snapshots","priority":"high","created":"2026-01-01","tags":["fixture"],"total":3,"done":1,"open":2,"percent":33,"deprecated":0,"liveness":"working"}],"open_audits":[{"id":"6fjangd7kvh3","slug":"2026-01-02-fixture-area","bucket":"open","area":"fixture-area","date":"2026-01-02","findings":2,"open_findings":1,"in_progress_findings":0,"done_findings":1,"dropped_findings":0}],"findings":{"open":1,"in_progress":0,"by_urgency":[{"key":"soon","count":1}],"by_component":[{"key":"fixturepipe","count":1}]},"revisit_due":0,"bad_epic_status":0} diff --git a/internal/cli/testdata/golden/task_acceptance_json.golden b/internal/cli/testdata/golden/task_acceptance_json.golden new file mode 100644 index 0000000..a7c099d --- /dev/null +++ b/internal/cli/testdata/golden/task_acceptance_json.golden @@ -0,0 +1 @@ +{"schema_version":"1.29","slug":"alpha-task","acceptance":[{"index":1,"checked":true,"text":"the first criterion is done"},{"index":2,"checked":false,"text":"the second criterion is not"}]} diff --git a/internal/cli/testdata/golden/task_info_json.golden b/internal/cli/testdata/golden/task_info_json.golden index c9c420c..31a7879 100644 --- a/internal/cli/testdata/golden/task_info_json.golden +++ b/internal/cli/testdata/golden/task_info_json.golden @@ -1 +1 @@ -{"schema_version":"1.28","task_info":{"id":"6fjangd7kvh0","slug":"alpha-task","status":"ready-to-start","epic":"01-fixture-epic","path":"/tasks/6fjangd7kvh0-alpha-task.md","ac":{"checked":0,"total":0}}} +{"schema_version":"1.29","task_info":{"id":"6fjangd7kvh0","slug":"alpha-task","status":"ready-to-start","epic":"01-fixture-epic","path":"/tasks/6fjangd7kvh0-alpha-task.md","ac":{"checked":1,"total":2}}} diff --git a/internal/cli/testdata/golden/task_list_json.golden b/internal/cli/testdata/golden/task_list_json.golden index cb775bf..51bf14c 100644 --- a/internal/cli/testdata/golden/task_list_json.golden +++ b/internal/cli/testdata/golden/task_list_json.golden @@ -1 +1 @@ -{"schema_version":"1.28","tasks":[{"id":"6fjangd7kvh0","slug":"alpha-task","status":"ready-to-start","epic":"01-fixture-epic","description":"A fully specified ready-to-start task for golden snapshots","effort":"S","tier":2,"priority":"high","autonomy_level":3,"created":"2026-01-02","updated_at":"2026-01-03","tags":["cli","testing"]},{"id":"6fjangd7kvh1","slug":"beta-task","status":"in-progress","epic":"01-fixture-epic","description":"An in-progress fixture task","effort":"M","tier":3,"priority":"medium","autonomy_level":2,"created":"2026-01-02","updated_at":"2026-01-03","tags":["cli"]},{"id":"6fjangd7kvh2","slug":"gamma-task","status":"completed","epic":"01-fixture-epic","description":"A completed fixture task","tier":1,"priority":"low","created":"2026-01-01","updated_at":"2026-01-02","tags":["docs"]}]} +{"schema_version":"1.29","tasks":[{"id":"6fjangd7kvh0","slug":"alpha-task","status":"ready-to-start","epic":"01-fixture-epic","description":"A fully specified ready-to-start task for golden snapshots","effort":"S","tier":2,"priority":"high","autonomy_level":3,"created":"2026-01-02","updated_at":"2026-01-03","tags":["cli","testing"]},{"id":"6fjangd7kvh1","slug":"beta-task","status":"in-progress","epic":"01-fixture-epic","description":"An in-progress fixture task","effort":"M","tier":3,"priority":"medium","autonomy_level":2,"created":"2026-01-02","updated_at":"2026-01-03","tags":["cli"]},{"id":"6fjangd7kvh2","slug":"gamma-task","status":"completed","epic":"01-fixture-epic","description":"A completed fixture task","tier":1,"priority":"low","created":"2026-01-01","updated_at":"2026-01-02","tags":["docs"]}]} diff --git a/internal/cli/testdata/golden/task_path_json.golden b/internal/cli/testdata/golden/task_path_json.golden index 7d44b43..3bf21b2 100644 --- a/internal/cli/testdata/golden/task_path_json.golden +++ b/internal/cli/testdata/golden/task_path_json.golden @@ -1 +1 @@ -{"schema_version":"1.28","path":"/tasks/6fjangd7kvh0-alpha-task.md"} +{"schema_version":"1.29","path":"/tasks/6fjangd7kvh0-alpha-task.md"} diff --git a/internal/cli/testdata/golden/task_show_json.golden b/internal/cli/testdata/golden/task_show_json.golden index d1f4828..809a4af 100644 --- a/internal/cli/testdata/golden/task_show_json.golden +++ b/internal/cli/testdata/golden/task_show_json.golden @@ -1 +1 @@ -{"schema_version":"1.28","task":{"id":"6fjangd7kvh0","slug":"alpha-task","status":"ready-to-start","epic":"01-fixture-epic","description":"A fully specified ready-to-start task for golden snapshots","effort":"S","tier":2,"priority":"high","autonomy_level":3,"created":"2026-01-02","updated_at":"2026-01-03","tags":["cli","testing"]},"body":"# Alpha Task\n\nBody for the alpha fixture task.\n"} +{"schema_version":"1.29","task":{"id":"6fjangd7kvh0","slug":"alpha-task","status":"ready-to-start","epic":"01-fixture-epic","description":"A fully specified ready-to-start task for golden snapshots","effort":"S","tier":2,"priority":"high","autonomy_level":3,"created":"2026-01-02","updated_at":"2026-01-03","tags":["cli","testing"]},"body":"# Alpha Task\n\nBody for the alpha fixture task.\n\n## Acceptance criteria\n\n- [x] the first criterion is done\n- [ ] the second criterion is not\n"} diff --git a/internal/cli/testdata/golden/template_list_json.golden b/internal/cli/testdata/golden/template_list_json.golden index d079c0f..063843e 100644 --- a/internal/cli/testdata/golden/template_list_json.golden +++ b/internal/cli/testdata/golden/template_list_json.golden @@ -1 +1 @@ -{"schema_version":"1.28","templates":[{"kind":"task","name":"default","description":"Standard task scaffold: objective, acceptance criteria, out-of-scope, related epic."},{"kind":"epic","name":"default","description":"Standard epic scaffold: goal, why-it's-its-own-epic, out-of-scope."},{"kind":"audit","name":"default","description":"Standard audit scaffold: findings + candidate tasks."},{"kind":"audit","name":"security","description":"Security review: threat model, checklist, severity-tagged findings."}]} +{"schema_version":"1.29","templates":[{"kind":"task","name":"default","description":"Standard task scaffold: objective, acceptance criteria, out-of-scope, related epic."},{"kind":"epic","name":"default","description":"Standard epic scaffold: goal, why-it's-its-own-epic, out-of-scope."},{"kind":"audit","name":"default","description":"Standard audit scaffold: findings + candidate tasks."},{"kind":"audit","name":"security","description":"Security review: threat model, checklist, severity-tagged findings."}]} diff --git a/internal/cli/testdata/golden/template_show_security_json.golden b/internal/cli/testdata/golden/template_show_security_json.golden index 913c5fa..60b97d7 100644 --- a/internal/cli/testdata/golden/template_show_security_json.golden +++ b/internal/cli/testdata/golden/template_show_security_json.golden @@ -1 +1 @@ -{"schema_version":"1.28","template":{"kind":"audit","name":"security","description":"Security review: threat model, checklist, severity-tagged findings."},"body":"\n# Security audit: \u003carea\u003e — \u003cdate\u003e\n\n\u003e Security review. Edit findings in place and flip each `**Status:**` as you work it.\n\n## Threat model\n\n- **Assets / trust boundaries:** \u003cwhat's worth protecting; where untrusted input crosses in\u003e\n- **Attacker \u0026 entry points:** \u003cwho, and through which surfaces\u003e\n\n## Review checklist\n\n- [ ] Authn / authz — every privileged path checks identity *and* permission\n- [ ] Input validation — untrusted input is parsed/escaped (injection, path traversal)\n- [ ] Secrets — no hard-coded creds; least-privilege tokens; nothing sensitive logged\n- [ ] Dependencies — known-vuln scan; versions pinned\n- [ ] Data at rest / in transit — encryption + safe defaults\n\n## Findings\n\n\u003c!-- One finding per issue, in this shape (un-fence it): --\u003e\n\n```\n#### H1. \u003ctitle\u003e · **Status:** open\n\n**File:** \u003cpath:line\u003e | **Component:** \u003ccomponent\u003e\n**Severity:** \u003ccritical|high|medium|low\u003e · **Effort:** \u003cXS|S|M|L\u003e · **Urgency:** \u003cacute|soon|eventually\u003e\n\n\u003cwhat's exploitable, the impact, and how\u003e\n\n**Recommendation:** \u003cthe fix\u003e\n```\n\n## Candidate tasks\n\n\u003c!-- Mirror each finding: ✅ done · ⚠️ partial · ⏳ open · ⛔ won't do --\u003e\n\n- ⏳ `tskflwctl task new \"\u003ctitle\u003e\" --epic \u003cid\u003e --tags security` — \u003cone line\u003e\n"} +{"schema_version":"1.29","template":{"kind":"audit","name":"security","description":"Security review: threat model, checklist, severity-tagged findings."},"body":"\n# Security audit: \u003carea\u003e — \u003cdate\u003e\n\n\u003e Security review. Edit findings in place and flip each `**Status:**` as you work it.\n\n## Threat model\n\n- **Assets / trust boundaries:** \u003cwhat's worth protecting; where untrusted input crosses in\u003e\n- **Attacker \u0026 entry points:** \u003cwho, and through which surfaces\u003e\n\n## Review checklist\n\n- [ ] Authn / authz — every privileged path checks identity *and* permission\n- [ ] Input validation — untrusted input is parsed/escaped (injection, path traversal)\n- [ ] Secrets — no hard-coded creds; least-privilege tokens; nothing sensitive logged\n- [ ] Dependencies — known-vuln scan; versions pinned\n- [ ] Data at rest / in transit — encryption + safe defaults\n\n## Findings\n\n\u003c!-- One finding per issue, in this shape (un-fence it): --\u003e\n\n```\n#### H1. \u003ctitle\u003e · **Status:** open\n\n**File:** \u003cpath:line\u003e | **Component:** \u003ccomponent\u003e\n**Severity:** \u003ccritical|high|medium|low\u003e · **Effort:** \u003cXS|S|M|L\u003e · **Urgency:** \u003cacute|soon|eventually\u003e\n\n\u003cwhat's exploitable, the impact, and how\u003e\n\n**Recommendation:** \u003cthe fix\u003e\n```\n\n## Candidate tasks\n\n\u003c!-- Mirror each finding: ✅ done · ⚠️ partial · ⏳ open · ⛔ won't do --\u003e\n\n- ⏳ `tskflwctl task new \"\u003ctitle\u003e\" --epic \u003cid\u003e --tags security` — \u003cone line\u003e\n"} diff --git a/internal/cli/testdata/planning/tasks/6fjangd7kvh0-alpha-task.md b/internal/cli/testdata/planning/tasks/6fjangd7kvh0-alpha-task.md index 28fe723..ac56ef6 100644 --- a/internal/cli/testdata/planning/tasks/6fjangd7kvh0-alpha-task.md +++ b/internal/cli/testdata/planning/tasks/6fjangd7kvh0-alpha-task.md @@ -14,3 +14,8 @@ updated_at: "2026-01-03" # Alpha Task Body for the alpha fixture task. + +## Acceptance criteria + +- [x] the first criterion is done +- [ ] the second criterion is not diff --git a/internal/core/service.go b/internal/core/service.go index eaaee4a..cb47a03 100644 --- a/internal/core/service.go +++ b/internal/core/service.go @@ -214,7 +214,7 @@ type LintResult struct { // epic-existence check) AND the epics themselves. Returns one LintResult per // task or epic with issues. func (s *Service) Lint() ([]LintResult, []domain.FileProblem, error) { - tasks, problems, err := s.store.ListTasks() + tasks, problems, err := s.store.ListTasksWithBodies() if err != nil { return nil, nil, err } @@ -230,7 +230,8 @@ func (s *Service) Lint() ([]LintResult, []domain.FileProblem, error) { validEpic := func(id string) bool { return valid[domain.EpicRefKey(id)] } var results []LintResult - for _, t := range tasks { + for _, tb := range tasks { + t := tb.Task // Active tasks get the full field lint; archived tasks are only checked for the // universal defects (missing/unrecognized frontmatter status, missing or drifted // id) — no point nagging about missing fields on a completed item, but a bad @@ -238,6 +239,9 @@ func (s *Service) Lint() ([]LintResult, []domain.FileProblem, error) { var issues []domain.Issue if t.Status.IsActive() { issues = domain.LintTask(t, validEpic) + // Body-aware: acceptance-criteria misconfigurations that would make the + // tally / `task ac` lie (botched checkbox, multiple acceptance sections). + issues = append(issues, domain.LintAcceptanceCriteria(tb.Body)...) } else { // Archived tasks skip the field nags but still get the universal checks: a // missing/unrecognized frontmatter status, and a missing stable id. diff --git a/internal/core/service_epic_test.go b/internal/core/service_epic_test.go index 843d472..9f4971b 100644 --- a/internal/core/service_epic_test.go +++ b/internal/core/service_epic_test.go @@ -20,6 +20,9 @@ func (nopStore) ListTasks() ([]domain.Task, []domain.FileProblem, error) { retur func (nopStore) GetTask(string) (domain.Task, string, error) { return domain.Task{}, "", domain.ErrNotFound } +func (nopStore) ListTasksWithBodies() ([]TaskWithBody, []domain.FileProblem, error) { + return nil, nil, nil +} func (nopStore) ResolveTaskPath(string) (string, error) { return "", domain.ErrNotFound } func (nopStore) ResolveEpicPath(string) (string, error) { return "", domain.ErrNotFound } func (nopStore) ResolveAuditPath(string) (string, error) { return "", domain.ErrNotFound } @@ -97,6 +100,7 @@ type fakeStore struct { auditCreateBodies []string // bodies passed to CreateAudit (parallel to createdAudits) epicCreateBodies []string // bodies passed to CreateEpic auditBodies map[string]string // slug → body, for GetAudit (finding queries) + taskBodies map[string]string // slug → body, for ListTasksWithBodies (acceptance lint) } func (f *fakeStore) GetAudit(slug string) (domain.Audit, string, error) { @@ -123,6 +127,13 @@ func (f *fakeStore) GetAuditByPath(path string) (domain.Audit, string, error) { func (f *fakeStore) ListTasks() ([]domain.Task, []domain.FileProblem, error) { return f.tasks, f.problems, nil } +func (f *fakeStore) ListTasksWithBodies() ([]TaskWithBody, []domain.FileProblem, error) { + out := make([]TaskWithBody, len(f.tasks)) + for i, t := range f.tasks { + out[i] = TaskWithBody{Task: t, Body: f.taskBodies[t.Slug]} + } + return out, f.problems, nil +} func (f *fakeStore) ListAudits() ([]domain.Audit, []domain.FileProblem, error) { return f.audits, nil, nil } diff --git a/internal/core/service_task.go b/internal/core/service_task.go index a1665cc..012143f 100644 --- a/internal/core/service_task.go +++ b/internal/core/service_task.go @@ -83,6 +83,37 @@ func (s *Service) TaskPath(slug string) (string, error) { return s.store.ResolveTaskPath(slug) } +// AcceptanceCriteria lists a task's acceptance criteria (read-only, for `task ac +// --list`). Returns the canonical slug (for the envelope) and the ordered criteria. +func (s *Service) AcceptanceCriteria(slug string) (string, []domain.Criterion, error) { + t, body, err := s.store.GetTask(slug) + if err != nil { + return "", nil, err + } + return t.Slug, domain.ListAcceptanceCriteria(body), nil +} + +// SetAcceptanceCriterion flips a task's nth (1-based) acceptance-criteria checkbox and +// writes the result through the atomic, frontmatter-preserving body-replace path +// (EditBody). Returns the reloaded task, the resulting body, and whether anything +// changed — false means the criterion was already in the target state, so no write was +// performed (and updated_at is not bumped). +func (s *Service) SetAcceptanceCriterion(slug string, n int, checked, dryRun bool) (domain.Task, string, bool, error) { + t, body, err := s.store.GetTask(slug) + if err != nil { + return domain.Task{}, "", false, err + } + newBody, err := domain.SetAcceptanceCriterion(body, n, checked) + if err != nil { + return domain.Task{}, "", false, err + } + if newBody == body { + return t, body, false, nil // already in the target state — no write + } + rt, rb, err := s.store.EditBody(slug, newBody, false, s.now(), dryRun) + return rt, rb, true, err +} + // EditTask opens a task for whole-file editing — the human face of mutation, // complementing the field-level `task set`. edit (run by the cli's $EDITOR layer) // receives the current file content and returns the new content; the store diff --git a/internal/core/store.go b/internal/core/store.go index ec45bc3..2ae3eef 100644 --- a/internal/core/store.go +++ b/internal/core/store.go @@ -14,6 +14,11 @@ import ( // and report unreadable files instead of dying on the first one. type TaskStore interface { ListTasks() ([]domain.Task, []domain.FileProblem, error) + // ListTasksWithBodies is ListTasks' scan with each task's markdown body kept + // alongside, so a body-aware pass (lint's acceptance-criteria checks) reads every + // file once instead of re-resolving each slug through GetTask. Same resilient-read + // contract: an unreadable file is a FileProblem, not fatal. + ListTasksWithBodies() ([]TaskWithBody, []domain.FileProblem, error) GetTask(slug string) (task domain.Task, body string, err error) // ResolveTaskPath returns a task's file path from its slug/id WITHOUT parsing — // so `task path` works even on a file whose frontmatter won't parse (the case @@ -85,6 +90,13 @@ type AuditWithFindings struct { Findings []domain.Finding } +// TaskWithBody is a task plus its markdown body, kept together by +// ListTasksWithBodies so lint's body-aware checks read each file once. +type TaskWithBody struct { + Task domain.Task + Body string +} + // AuditStore is the audit-persistence port. type AuditStore interface { ListAudits() ([]domain.Audit, []domain.FileProblem, error) diff --git a/internal/core/usecases_test.go b/internal/core/usecases_test.go index 53bf74d..d6fd9a1 100644 --- a/internal/core/usecases_test.go +++ b/internal/core/usecases_test.go @@ -71,6 +71,33 @@ func TestService_Lint(t *testing.T) { } } +// Lint is body-aware for active tasks: an acceptance-criteria misconfiguration +// (here a botched checkbox) surfaces via the ListTasksWithBodies scan. +func TestService_Lint_FlagsMalformedAcceptance(t *testing.T) { + svc := NewService(&fakeStore{ + epics: []domain.Epic{{ID: "01-e1", Status: "active", Priority: "medium", Description: "e"}}, + tasks: []domain.Task{{ID: "6fjangd7kvh1", Slug: "t", Status: domain.StatusInProgress, + Epic: "01-e1", Description: "d", Tags: []string{"x"}, Tier: 3, Priority: "medium", + Effort: "Unknown", Created: "2026-06-12"}}, + taskBodies: map[string]string{"t": "## Acceptance criteria\n\n- [] botched\n"}, + }) + results, _, err := svc.Lint() + if err != nil { + t.Fatal(err) + } + found := false + for _, r := range results { + for _, i := range r.Issues { + if i.Field == "acceptance" { + found = true + } + } + } + if !found { + t.Errorf("Lint should flag the malformed acceptance checkbox via ListTasksWithBodies, got %+v", results) + } +} + func TestService_ListAudits_BucketFilters(t *testing.T) { svc := NewService(&fakeStore{audits: []domain.Audit{ {Slug: "a-open", Bucket: domain.AuditOpen}, diff --git a/internal/domain/body.go b/internal/domain/body.go index 4c92ea3..3e5c211 100644 --- a/internal/domain/body.go +++ b/internal/domain/body.go @@ -1,6 +1,7 @@ package domain import ( + "fmt" "regexp" "strings" ) @@ -13,6 +14,22 @@ type ACCount struct { Total int } +// Criterion is one acceptance-criteria checkbox for `task ac --list`: its 1-based +// position, whether it's checked, and the first-line text after the checkbox. +type Criterion struct { + Index int + Checked bool + Text string +} + +// acCheckbox is an acceptance-criteria checkbox located in a body: its 0-based line +// index (so a flip can rewrite exactly that line) and current state/text. +type acCheckbox struct { + line int + checked bool + text string +} + // The body-structure model is line-oriented and code-fence aware: a `##` heading // or a `- [ ]` checkbox inside a fenced block is example prose, not structure, so // the scanners skip fenced lines — the same "don't treat code as structure" @@ -111,33 +128,203 @@ func Section(body, name string) (text string, ok bool) { return trimTrailingBlankLines(lines[start:]), true } -// CountAcceptanceCriteria tallies the task-list checkboxes inside the body's -// acceptance-criteria section (the first heading whose title contains -// "acceptance"). No such section — or none with checkboxes — yields a zero tally. -// Checkboxes in fenced code blocks are ignored. -func CountAcceptanceCriteria(body string) ACCount { - sec, ok := Section(body, "acceptance") // Section already normalizes newlines - if !ok { - return ACCount{} - } - var c ACCount - var fence fenceScanner - for _, ln := range strings.Split(sec, "\n") { +// scanAcceptanceCheckboxes returns the body split on "\n" (newline-normalized) plus +// the task-list checkboxes inside its acceptance-criteria section — the first heading +// whose title contains "acceptance", up to the next heading of the same or higher +// level. Fence-aware in a single pass (a `##`/`- [ ]` inside a code fence is example +// prose, not structure). One scanner backs the tally, the list, and the flip. +func scanAcceptanceCheckboxes(body string) (lines []string, boxes []acCheckbox) { + lines = strings.Split(normalizeNewlines(body), "\n") + var ( + fence fenceScanner + inSection bool + sectionLvl int + ) + for i, ln := range lines { if fence.inCode(ln) { continue } - m := bodyCheckboxRe.FindStringSubmatch(ln) - if m == nil { + if m := bodyHeadingRe.FindStringSubmatch(ln); m != nil { + lvl := len(m[1]) + switch { + case !inSection: + if strings.Contains(strings.ToLower(m[2]), "acceptance") { + inSection, sectionLvl = true, lvl + } + case lvl <= sectionLvl: // a same-or-higher heading ends the section + return lines, boxes + } continue } + if inSection { + if m := bodyCheckboxRe.FindStringSubmatch(ln); m != nil { + boxes = append(boxes, acCheckbox{line: i, checked: m[1] == "x" || m[1] == "X", text: checkboxText(ln)}) + } + } + } + return lines, boxes +} + +// CountAcceptanceCriteria tallies the acceptance-criteria checkboxes. No such +// section — or none with checkboxes — yields a zero tally. +func CountAcceptanceCriteria(body string) ACCount { + _, boxes := scanAcceptanceCheckboxes(body) + var c ACCount + for _, b := range boxes { c.Total++ - if m[1] == "x" || m[1] == "X" { + if b.checked { c.Checked++ } } return c } +// ListAcceptanceCriteria returns the acceptance criteria in body order, 1-based — +// the `task ac --list` view an agent then flips by index. +func ListAcceptanceCriteria(body string) []Criterion { + _, boxes := scanAcceptanceCheckboxes(body) + out := make([]Criterion, len(boxes)) + for i, b := range boxes { + out[i] = Criterion{Index: i + 1, Checked: b.checked, Text: b.text} + } + return out +} + +// SetAcceptanceCriterion flips the 1-based nth acceptance-criteria checkbox to +// checked/unchecked, returning the new body. Only that one checkbox's `[ ]`/`[x]` +// is rewritten — every other byte (frontmatter is handled upstream) is preserved. +// It is idempotent: flipping to the current state returns the body unchanged (the +// caller can skip the write). ErrValidation when there's no acceptance section or n +// is out of range. +func SetAcceptanceCriterion(body string, n int, checked bool) (string, error) { + lines, boxes := scanAcceptanceCheckboxes(body) + if len(boxes) == 0 { + return "", fmt.Errorf("%w: task has no acceptance criteria to %s", ErrValidation, checkWord(checked)) + } + if n < 1 || n > len(boxes) { + return "", fmt.Errorf("%w: criterion %d out of range (have %d)", ErrValidation, n, len(boxes)) + } + box := boxes[n-1] + if box.checked == checked { + return body, nil // already in the target state — no-op + } + lines[box.line] = flipCheckbox(lines[box.line], checked) + return strings.Join(lines, "\n"), nil +} + +// Misconfiguration guards for `task ac` / the `ac:` tally, which key off the first +// heading containing "acceptance" and count only well-formed checkboxes. A list item +// whose bracket holds only spaces/tabs/x/X but ISN'T the canonical `[ ]`/`[x]`/`[X]` +// (e.g. `[]`, `[ x]`, `[ ]`) is a botched checkbox that the tally silently drops. +// The class is deliberately narrow — `[1]`, `[-]`, and `[text](url)` links are NOT +// flagged — so a lint warning here is high-confidence, not noise. +var ( + acListItemRe = regexp.MustCompile(`^[ \t]*[-*+][ \t]+(.*)$`) + acCheckboxOKRe = regexp.MustCompile(`^\[[ xX]\]`) // the canonical, valid marker + acCheckboxyRe = regexp.MustCompile(`^\[[ \txX]*\]`) // bracket of only blanks/x/X (botched) +) + +// LintAcceptanceCriteria reports misconfigurations that would make the acceptance +// tally / `task ac` list lie: a botched checkbox in the (first) acceptance section +// that the scanner silently skips, and more than one acceptance section (only the +// first is used). Empty when the body's acceptance criteria are well-formed. The +// checks are fence-aware, matching the scanner they guard. +func LintAcceptanceCriteria(body string) []Issue { + lines := strings.Split(normalizeNewlines(body), "\n") + var ( + issues []Issue + fence fenceScanner + acSections int + inFirst bool + firstLvl int + firstDone bool + ) + for _, ln := range lines { + if fence.inCode(ln) { + continue + } + if m := bodyHeadingRe.FindStringSubmatch(ln); m != nil { + lvl := len(m[1]) + isAcc := isAcceptanceSectionHeading(m[2]) + if isAcc { + acSections++ + } + switch { + case isAcc && !inFirst && !firstDone: + inFirst, firstLvl = true, lvl + case inFirst && lvl <= firstLvl: + inFirst, firstDone = false, true + } + continue + } + if inFirst { + if bad, ok := malformedCheckbox(ln); ok { + issues = append(issues, Issue{Field: "acceptance", Message: fmt.Sprintf("malformed acceptance checkbox %q — use `- [ ]` or `- [x]` (it is not counted as written)", bad)}) + } + } + } + if acSections > 1 { + issues = append(issues, Issue{Field: "acceptance", Message: fmt.Sprintf("%d acceptance-criteria sections — the tally and `task ac` use the first and ignore the rest; merge them", acSections)}) + } + return issues +} + +// isAcceptanceSectionHeading is the PRECISE test the lint guard uses to identify an +// acceptance-criteria section — the canonical "Acceptance criteria" name, not merely +// any heading that mentions "acceptance". This is deliberately stricter than the +// tally scanner's substring match: without it, a "## Progress — notes on acceptance +// criteria" heading would be miscounted as a second acceptance section (a false +// positive the guard itself must not raise). +func isAcceptanceSectionHeading(title string) bool { + t := strings.ToLower(strings.TrimSpace(title)) + return t == "acceptance" || strings.HasPrefix(t, "acceptance criteria") || strings.HasPrefix(t, "acceptance:") +} + +// malformedCheckbox reports whether line is a list item whose leading bracket is a +// botched checkbox (blanks/x/X only, but not the canonical form), returning that +// `[…]` token for the message. +func malformedCheckbox(line string) (string, bool) { + m := acListItemRe.FindStringSubmatch(line) + if m == nil || acCheckboxOKRe.MatchString(m[1]) { + return "", false + } + if tok := acCheckboxyRe.FindString(m[1]); tok != "" { + return tok, true + } + return "", false +} + +func checkWord(checked bool) string { + if checked { + return "check" + } + return "uncheck" +} + +// checkboxText is the criterion text: everything after the `- [x]` marker on the +// checkbox line, trimmed (continuation lines aren't separate criteria). +func checkboxText(line string) string { + if loc := bodyCheckboxRe.FindStringIndex(line); loc != nil { + return strings.TrimSpace(line[loc[1]:]) + } + return strings.TrimSpace(line) +} + +// flipCheckbox rewrites just the single character inside a checkbox line's brackets +// to "x" (checked) or " " (unchecked), leaving indentation, marker, and text intact. +func flipCheckbox(line string, checked bool) string { + loc := bodyCheckboxRe.FindStringSubmatchIndex(line) + if loc == nil { + return line + } + mark := " " + if checked { + mark = "x" + } + // loc[2]:loc[3] is capture group 1 — the single char between the brackets. + return line[:loc[2]] + mark + line[loc[3]:] +} + // normalizeNewlines folds CRLF (and lone CR) to LF so the line-oriented scanners // don't miss `\r`-terminated headings — files touched on Windows or under a // core.autocrlf checkout, or a CRLF body piped through --body-file. diff --git a/internal/domain/body_test.go b/internal/domain/body_test.go index fdede7a..5a7fc2b 100644 --- a/internal/domain/body_test.go +++ b/internal/domain/body_test.go @@ -1,6 +1,7 @@ package domain import ( + "errors" "strings" "testing" ) @@ -119,6 +120,200 @@ func TestFenceScanner_NestedFencesDoNotLeak(t *testing.T) { } } +func TestListAcceptanceCriteria(t *testing.T) { + got := ListAcceptanceCriteria(acBody) + want := []Criterion{ + {Index: 1, Checked: true, Text: "first is done"}, + {Index: 2, Checked: false, Text: "second is not"}, + {Index: 3, Checked: true, Text: "third is done (capital X)"}, + } + if len(got) != len(want) { + t.Fatalf("ListAcceptanceCriteria len = %d, want %d: %+v", len(got), len(want), got) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("criterion %d = %+v, want %+v", i+1, got[i], want[i]) + } + } +} + +func TestListAcceptanceCriteria_NoSection(t *testing.T) { + if got := ListAcceptanceCriteria("# Title\n\njust prose\n"); len(got) != 0 { + t.Fatalf("no AC section should list nothing, got %+v", got) + } +} + +func TestSetAcceptanceCriterion(t *testing.T) { + // Check the currently-unchecked #2. + out, err := SetAcceptanceCriterion(acBody, 2, true) + if err != nil { + t.Fatal(err) + } + got := ListAcceptanceCriteria(out) + if !got[1].Checked { + t.Errorf("criterion 2 should be checked after the flip:\n%s", out) + } + // Only that one line changed: #1 and #3 stay checked, everything else identical. + if !got[0].Checked || !got[2].Checked || got[1].Text != "second is not" { + t.Errorf("flip must not disturb other criteria or text:\n%s", out) + } + // The non-AC checkbox under ## Notes must be untouched. + if !strings.Contains(out, "- [ ] this checkbox is NOT acceptance criteria") { + t.Errorf("flip must not touch checkboxes outside the AC section:\n%s", out) + } +} + +func TestSetAcceptanceCriterion_Uncheck(t *testing.T) { + out, err := SetAcceptanceCriterion(acBody, 1, false) + if err != nil { + t.Fatal(err) + } + if ListAcceptanceCriteria(out)[0].Checked { + t.Errorf("criterion 1 should be unchecked:\n%s", out) + } +} + +func TestSetAcceptanceCriterion_Idempotent(t *testing.T) { + out, err := SetAcceptanceCriterion(acBody, 1, true) // #1 already checked + if err != nil { + t.Fatal(err) + } + if out != acBody { + t.Errorf("flipping to the current state must return the body unchanged") + } +} + +func TestSetAcceptanceCriterion_OutOfRange(t *testing.T) { + if _, err := SetAcceptanceCriterion(acBody, 9, true); !errors.Is(err, ErrValidation) { + t.Errorf("out-of-range index should be ErrValidation, got %v", err) + } + if _, err := SetAcceptanceCriterion(acBody, 0, true); !errors.Is(err, ErrValidation) { + t.Errorf("index 0 should be ErrValidation, got %v", err) + } +} + +func TestSetAcceptanceCriterion_NoSection(t *testing.T) { + if _, err := SetAcceptanceCriterion("# Title\n\nno criteria\n", 1, true); !errors.Is(err, ErrValidation) { + t.Errorf("no AC section should be ErrValidation, got %v", err) + } +} + +// A multi-line criterion (a checkbox with an indented continuation line — the shape +// real tasks use) is ONE criterion: the continuation isn't a separate checkbox, and a +// flip touches only the checkbox line, leaving the continuation intact. +func TestSetAcceptanceCriterion_MultiLine(t *testing.T) { + body := "## Acceptance criteria\n\n- [ ] first criterion spans\n a continuation line\n- [x] second is done\n" + cs := ListAcceptanceCriteria(body) + if len(cs) != 2 || cs[0].Text != "first criterion spans" { + t.Fatalf("multi-line criterion should count once: %+v", cs) + } + out, err := SetAcceptanceCriterion(body, 1, true) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(out, "- [x] first criterion spans") || !strings.Contains(out, " a continuation line") { + t.Errorf("flip must tick the checkbox line and preserve the continuation:\n%s", out) + } +} + +// check-then-uncheck restores the body byte-for-byte — the surgical guarantee. +func TestSetAcceptanceCriterion_RoundTripByteIdentical(t *testing.T) { + checked, err := SetAcceptanceCriterion(acBody, 2, true) + if err != nil { + t.Fatal(err) + } + back, err := SetAcceptanceCriterion(checked, 2, false) + if err != nil { + t.Fatal(err) + } + if back != acBody { + t.Errorf("check then uncheck must restore the body exactly:\n got %q\nwant %q", back, acBody) + } +} + +// List and flip both skip a checkbox inside a fenced block: the index numbers only +// the real criteria, and a flip targets the real checkbox, never the fenced example. +func TestAcceptanceCriteria_FenceAware(t *testing.T) { + body := "## Acceptance criteria\n\n- [x] real one\n\n```\n- [ ] fenced example, not real\n```\n\n- [ ] real two\n" + cs := ListAcceptanceCriteria(body) + if len(cs) != 2 || cs[1].Text != "real two" { + t.Fatalf("fenced checkbox must not be listed: %+v", cs) + } + out, err := SetAcceptanceCriterion(body, 2, true) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(out, "- [x] real two") || strings.Contains(out, "- [x] fenced example") { + t.Errorf("flip index 2 must target the real checkbox, not the fenced one:\n%s", out) + } +} + +func TestLintAcceptanceCriteria_Clean(t *testing.T) { + if iss := LintAcceptanceCriteria(acBody); len(iss) != 0 { + t.Errorf("well-formed acceptance criteria should not lint, got %+v", iss) + } +} + +func TestLintAcceptanceCriteria_Malformed(t *testing.T) { + body := "## Acceptance criteria\n\n- [x] ok\n- [] empty\n- [ x] spaced\n- [ ] two spaces\n" + iss := LintAcceptanceCriteria(body) + if len(iss) != 3 { + t.Fatalf("expected 3 malformed-checkbox warnings, got %d: %+v", len(iss), iss) + } + for _, i := range iss { + if i.Field != "acceptance" || !strings.Contains(i.Message, "malformed") { + t.Errorf("unexpected issue: %+v", i) + } + } +} + +// The malformed heuristic is deliberately narrow: citations, partial markers, and +// links must NOT be flagged (they'd break lint-clean on legit content). +func TestLintAcceptanceCriteria_NoFalsePositives(t *testing.T) { + body := "## Acceptance criteria\n\n- [x] ok\n- [1] a citation\n- [-] a partial marker\n- [see docs](http://x) a link\n" + if iss := LintAcceptanceCriteria(body); len(iss) != 0 { + t.Errorf("citations/markers/links must not be flagged, got %+v", iss) + } +} + +func TestLintAcceptanceCriteria_MultipleSections(t *testing.T) { + body := "## Acceptance criteria\n\n- [ ] a\n\n## Acceptance criteria\n\n- [ ] b\n" + iss := LintAcceptanceCriteria(body) + found := false + for _, i := range iss { + if strings.Contains(i.Message, "acceptance-criteria sections") { + found = true + } + } + if !found { + t.Errorf("duplicate acceptance sections should be flagged: %+v", iss) + } +} + +// A later heading that merely MENTIONS "acceptance" (e.g. a Progress note) must not +// be miscounted as a second acceptance section — the guard must not false-positive on +// its own kind of prose. (Regression: this bit the tool's own self-hosted task.) +func TestLintAcceptanceCriteria_MentionHeadingNotCounted(t *testing.T) { + body := "## Acceptance criteria\n\n- [ ] a\n\n## Progress — notes on acceptance criteria\n\nsome text\n" + if iss := LintAcceptanceCriteria(body); len(iss) != 0 { + t.Errorf("a heading merely mentioning 'acceptance' must not count as a second section, got %+v", iss) + } +} + +func TestLintAcceptanceCriteria_FencedNotFlagged(t *testing.T) { + body := "## Acceptance criteria\n\n- [x] real\n\n```\n- [] fenced botched\n```\n" + if iss := LintAcceptanceCriteria(body); len(iss) != 0 { + t.Errorf("a malformed checkbox inside a fence must not be flagged, got %+v", iss) + } +} + +// A botched checkbox OUTSIDE any acceptance section isn't the tally's business. +func TestLintAcceptanceCriteria_OnlyInSection(t *testing.T) { + if iss := LintAcceptanceCriteria("# Title\n\n- [] not in an AC section\n"); len(iss) != 0 { + t.Errorf("malformed checkbox outside the AC section must not be flagged, got %+v", iss) + } +} + func TestSection_IgnoresFencedHeadings(t *testing.T) { body := "## Real\n\ntext.\n\n```\n## Fake heading in a fence\n```\n\nmore text under Real.\n" sec, ok := Section(body, "real") diff --git a/internal/store/fsstore.go b/internal/store/fsstore.go index 49dbdf9..894421a 100644 --- a/internal/store/fsstore.go +++ b/internal/store/fsstore.go @@ -75,6 +75,20 @@ func (s *FS) ListTasks() ([]domain.Task, []domain.FileProblem, error) { }) } +// ListTasksWithBodies is ListTasks' scan with each task's body kept alongside (one +// pass), so lint's acceptance-criteria checks read every file once — the task twin of +// ListAuditsWithFindings. +func (s *FS) ListTasksWithBodies() ([]core.TaskWithBody, []domain.FileProblem, error) { + return scanDir(s.tasksDir, func(path string, content []byte) (core.TaskWithBody, error) { + t, err := parseTask(content, path) + if err != nil { + return core.TaskWithBody{}, err + } + _, body := splitFrontmatter(content) + return core.TaskWithBody{Task: t, Body: string(body)}, nil + }) +} + // GetTask returns a single task plus its markdown body. func (s *FS) GetTask(slug string) (domain.Task, string, error) { path, err := s.resolve(slug) diff --git a/internal/store/fsstore_test.go b/internal/store/fsstore_test.go index 8a24005..0a95702 100644 --- a/internal/store/fsstore_test.go +++ b/internal/store/fsstore_test.go @@ -13,6 +13,25 @@ func writeTask(t *testing.T, root, status, name, content string) { testutil.Write(t, path, out) } +// TestFS_ListTasksWithBodies pins the body-carrying scan (the task twin of +// ListAuditsWithFindings): each task returns with its markdown body — which the +// acceptance-criteria lint reads — alongside the frontmatter ListTasks parses. +func TestFS_ListTasksWithBodies(t *testing.T) { + root := t.TempDir() + writeTask(t, root, "ready-to-start", "a.md", "---\nstatus: ready-to-start\nepic: e1\ntags: [x]\n---\n# A\n\n## Acceptance criteria\n\n- [x] done\n") + + got, problems, err := NewFS(root).ListTasksWithBodies() + if err != nil || len(problems) != 0 { + t.Fatalf("ListTasksWithBodies: %v / %+v", err, problems) + } + if len(got) != 1 || got[0].Task.Slug != "a" { + t.Fatalf("want 1 task 'a', got %+v", got) + } + if !strings.Contains(got[0].Body, "## Acceptance criteria") || !strings.Contains(got[0].Body, "- [x] done") { + t.Errorf("body not carried through the scan:\n%q", got[0].Body) + } +} + func TestFS_ListTasks(t *testing.T) { root := t.TempDir() writeTask(t, root, "ready-to-start", "alpha.md", diff --git a/internal/wire/dto.go b/internal/wire/dto.go index 93e60d5..0efc395 100644 --- a/internal/wire/dto.go +++ b/internal/wire/dto.go @@ -77,6 +77,24 @@ func ToTaskInfoJSON(t domain.Task, ac domain.ACCount, path string) TaskInfoJSON } } +// CriterionJSON is one acceptance-criteria checkbox for `task ac --list --json` — +// the list an agent then flips by index with `task ac --check/--uncheck`. +type CriterionJSON struct { + Index int `json:"index" jsonschema:"description=1-based position of the criterion in the acceptance section"` + Checked bool `json:"checked" jsonschema:"description=whether the checkbox is checked (- [x])"` + Text string `json:"text" jsonschema:"description=the criterion text — the first line after the checkbox"` +} + +// ToCriteriaJSON maps the domain criteria to their wire DTOs (never nil — an empty +// acceptance list marshals to []). +func ToCriteriaJSON(cs []domain.Criterion) []CriterionJSON { + out := make([]CriterionJSON, len(cs)) + for i, c := range cs { + out[i] = CriterionJSON{Index: c.Index, Checked: c.Checked, Text: c.Text} + } + return out +} + // FindingsTallyJSON is an audit's finding disposition tally (the `findings` field // of `audit info`) — the audit analogue of a task's acceptance-criteria tally. type FindingsTallyJSON struct { diff --git a/internal/wire/envelopes.go b/internal/wire/envelopes.go index 7331c42..0929c92 100644 --- a/internal/wire/envelopes.go +++ b/internal/wire/envelopes.go @@ -120,6 +120,20 @@ func ToAuditInfoEnvelope(a domain.Audit, path string) AuditInfoEnvelope { return AuditInfoEnvelope{SchemaVersion: SchemaVersion, AuditInfo: ToAuditInfoJSON(a, path)} } +// AcceptanceEnvelope wraps `task ac --list --json` — a task's acceptance criteria +// with their checked state (the list an agent flips by index). A flip +// (`--check`/`--uncheck`) instead returns a task_mutation envelope (it edits the body). +type AcceptanceEnvelope struct { + SchemaVersion string `json:"schema_version"` + Slug string `json:"slug"` + Acceptance []CriterionJSON `json:"acceptance"` +} + +// ToAcceptanceEnvelope builds the `task ac --list --json` envelope value. +func ToAcceptanceEnvelope(slug string, cs []domain.Criterion) AcceptanceEnvelope { + return AcceptanceEnvelope{SchemaVersion: SchemaVersion, Slug: slug, Acceptance: ToCriteriaJSON(cs)} +} + // TaskMutationEnvelope is `task set` / `task append` / `task set --body` under // --json: the reloaded task, dry_run, and (for the body commands) the resulting // body. Separate from TaskShowEnvelope so the mutation-only dry_run stays off the @@ -576,6 +590,7 @@ type jsonEnvelopes struct { Board BoardEnvelope `json:"board"` TaskShow TaskShowEnvelope `json:"task_show"` TaskInfo TaskInfoEnvelope `json:"task_info"` + Acceptance AcceptanceEnvelope `json:"acceptance"` Path PathEnvelope `json:"path"` TaskMutation TaskMutationEnvelope `json:"task_mutation"` EpicMutation EpicMutationEnvelope `json:"epic_mutation"` diff --git a/internal/wire/envelopes_test.go b/internal/wire/envelopes_test.go index 8de0027..249bfbd 100644 --- a/internal/wire/envelopes_test.go +++ b/internal/wire/envelopes_test.go @@ -58,6 +58,9 @@ func TestJSONSchema_ValidatesRealOutput(t *testing.T) { return emit(w, ToTaskInfoEnvelope(task, domain.ACCount{Checked: 1, Total: 3}, "/root/tasks/alpha.md")) }}, {"PathEnvelope", func(w io.Writer) error { return emit(w, ToPathEnvelope("/root/tasks/alpha.md")) }}, + {"AcceptanceEnvelope", func(w io.Writer) error { + return emit(w, ToAcceptanceEnvelope("alpha", []domain.Criterion{{Index: 1, Checked: true, Text: "done"}, {Index: 2, Checked: false, Text: "todo"}})) + }}, {"AuditInfoEnvelope", func(w io.Writer) error { return emit(w, ToAuditInfoEnvelope(domain.Audit{Slug: "x", Bucket: domain.AuditOpen, Findings: 3, OpenFindings: 1, ActiveFindings: 1, DoneFindings: 1}, "/root/audits/x.md")) }}, diff --git a/internal/wire/schema_comments.json b/internal/wire/schema_comments.json index 4ba51f8..4e4289b 100644 --- a/internal/wire/schema_comments.json +++ b/internal/wire/schema_comments.json @@ -12,6 +12,7 @@ "github.com/andy-esch/taskflow/internal/domain.Audit.Updated": "Updated is the audit's own last-edited date (stamped by edit/append). Unlike\nDate — immutable, part of the slug — this advances on each content edit. A\nbucket move (close/reopen/defer) rewrites the `bucket:` frontmatter in place but\ndoes NOT touch this stamp.", "github.com/andy-esch/taskflow/internal/domain.AuditBucket": "AuditBucket is an audit's lifecycle state, authoritative in frontmatter (ADR-0003 §4).", "github.com/andy-esch/taskflow/internal/domain.Class": "Class is a domain error's outcome category — the single, adapter-neutral classification of a sentinel-wrapped error.", + "github.com/andy-esch/taskflow/internal/domain.Criterion": "Criterion is one acceptance-criteria checkbox for `task ac --list`: its 1-based position, whether it's checked, and the first-line text after the checkbox.", "github.com/andy-esch/taskflow/internal/domain.Descriptor": "Descriptor is the per-entity metadata the tool would otherwise hand-enumerate in a `switch kind` at every layer.", "github.com/andy-esch/taskflow/internal/domain.Descriptor.AuthoringFields": "frontmatter a drafter fills in (not tool-managed stamps)", "github.com/andy-esch/taskflow/internal/domain.Descriptor.Conventions": "short, factual \"how to write it\" rules", @@ -50,6 +51,7 @@ "github.com/andy-esch/taskflow/internal/domain.TransitionParam": "TransitionParam marks an optional argument a lifecycle verb accepts beyond the move itself.", "github.com/andy-esch/taskflow/internal/domain.UnsetField": "UnsetField is a sentinel value in a SetFields update map: the key is removed from the frontmatter instead of being assigned.", "github.com/andy-esch/taskflow/internal/wire.ACJSON": "ACJSON is a task's acceptance-criteria checkbox tally (the `ac` field of `task info`): how many criteria are checked out of the total.", + "github.com/andy-esch/taskflow/internal/wire.AcceptanceEnvelope": "AcceptanceEnvelope wraps `task ac --list --json` — a task's acceptance criteria with their checked state (the list an agent flips by index).", "github.com/andy-esch/taskflow/internal/wire.AuditInfoEnvelope": "AuditInfoEnvelope wraps `audit info --json` — the token-cheap audit metadata read (path + bucket + finding tally, no body), the audit counterpart to TaskInfoEnvelope.", "github.com/andy-esch/taskflow/internal/wire.AuditInfoJSON": "AuditInfoJSON is the token-cheap metadata read for an audit (`audit info`): where the file lives, its bucket, and the finding tally — no body.", "github.com/andy-esch/taskflow/internal/wire.AuditJSON": "AuditJSON is the wire shape of an audit inside the --json envelopes.", @@ -63,6 +65,7 @@ "github.com/andy-esch/taskflow/internal/wire.CountByJSON": "CountByJSON is one bucket of a finding breakdown — an urgency value or a top-level component, and its count.", "github.com/andy-esch/taskflow/internal/wire.CreatedEnvelope": "CreatedEnvelope is `task/epic/audit new --json`.", "github.com/andy-esch/taskflow/internal/wire.CreatedItem": "CreatedItem is the created document inside CreatedEnvelope.", + "github.com/andy-esch/taskflow/internal/wire.CriterionJSON": "CriterionJSON is one acceptance-criteria checkbox for `task ac --list --json` — the list an agent then flips by index with `task ac --check/--uncheck`.", "github.com/andy-esch/taskflow/internal/wire.DoctorEnvelope": "DoctorEnvelope is `doctor --json`: the linkback audit result.", "github.com/andy-esch/taskflow/internal/wire.DoctorProblem": "DoctorProblem is one linkback inconsistency: the offending repo + a message.", "github.com/andy-esch/taskflow/internal/wire.EpicJSON": "EpicJSON is epic list output: the shared meta (embedded, so `epic list` and `epic show` can't drift) plus the task rollup.", diff --git a/internal/wire/wire.go b/internal/wire/wire.go index 5d11107..efcf307 100644 --- a/internal/wire/wire.go +++ b/internal/wire/wire.go @@ -102,7 +102,10 @@ import ( // bucket + finding disposition tally `findings:{total,open,in_progress,done,dropped}`, // no body; the audit counterpart to `task_info`) added. The `path` envelope now also // backs `epic path` / `audit path` (unchanged shape). -const SchemaVersion = "1.28" +// 1.29: the `acceptance` envelope (`task ac --list` — a task's acceptance criteria, +// each `{index, checked, text}`, the list an agent flips by index) added. A flip +// (`task ac --check/--uncheck`) returns the existing `task_mutation` envelope. +const SchemaVersion = "1.29" // EncodeJSON writes the payload as compact (un-indented) JSON with a single // trailing newline. Machine output: pretty-printing is pure token cost for a diff --git a/planning/tasks/6fpfcecdymca-structure-aware-body-mutation-and-metadata-reads.md b/planning/tasks/6fpfcecdymca-structure-aware-body-mutation-and-metadata-reads.md index f680f56..77f4a1b 100644 --- a/planning/tasks/6fpfcecdymca-structure-aware-body-mutation-and-metadata-reads.md +++ b/planning/tasks/6fpfcecdymca-structure-aware-body-mutation-and-metadata-reads.md @@ -74,7 +74,7 @@ so the next agent finds them without dogfooding-by-discovery. `## Progress Log`, creating the section only when absent — never a duplicate header. Atomic; `--json` returns the task envelope; `--dry-run` previews. -- [ ] `task ac --list` numbers the criteria; `--check `/`--uncheck ` +- [x] `task ac --list` numbers the criteria; `--check `/`--uncheck ` flip the box atomically, preserving surrounding body; `--json` supported. - [x] `task path ` prints the absolute file path and nothing else. - [x] `task info --json` returns `{path, status, epic, @@ -227,3 +227,53 @@ real ones: Reviewer PASSED lenses 1–3, 5, 6, 8 (narrowing-under-`--json`, parse-free resolution, tally trust, port churn, section semantics, completion claims) and un-checked no boxes. Full suite + lint green; goldens + docs regenerated. + +## Progress (2026-07-16) — `task ac` shipped (item 2) + +The acceptance-criteria checkbox CLI — the fast-follow — is done, closing item 2 +(checked off with the command itself): + +- **`task ac `** (default / `--list`) — numbered acceptance checklist; + `--json` returns the new `acceptance` envelope `[{index,checked,text}]` (schema + 1.28 → 1.29). +- **`task ac --check ` / `--uncheck `** — flip one criterion by + 1-based index (substring matching deliberately not offered). The flip rewrites + only that checkbox's `[ ]`/`[x]` char and routes through the atomic, + frontmatter-preserving `EditBody` path; `--json` returns the `task_mutation` + envelope. Idempotent: flipping to the current state is a no-op with no write (no + spurious `updated_at` bump); `--dry-run` previews; out-of-range / no-AC-section → + ErrValidation (exit 11). + +Reuses the fence-aware AC enumerator (`scanAcceptanceCheckboxes`) that already +backs `task info`'s tally — one parser, three consumers (count, list, flip). +Verified surgical end-to-end: a `--check`→`--uncheck` round-trip restores the file +byte-for-byte. Tests: domain (list/flip/uncheck/idempotent/out-of-range/no-section) ++ CLI (list, list --json, flip-file, frontmatter-preserved, dry-run, no-op, +mutual-exclusion, mutation envelope). Golden `task_acceptance_json` added (the +fixture task gained an acceptance section, so `task_info`'s tally is now a real +1/2). docgen + comment map + README updated. Full suite + lint green. + +**Remaining:** only `task log` (item 1) — still parked pending the canonical +Progress-section shape decision. + +## Progress (2026-07-16) — misconfiguration guard for acceptance criteria + +Raised in review: the `ac:` tally / `task ac` key off "a heading containing 'acceptance' ++ well-formed checkboxes", so a misconfigured task made them silently lie. Added a lint +guard so misconfiguration is loud, not a false positive: + +- **`lint` now flags** (a) a botched checkbox in the acceptance section that the scanner + silently drops (`[]`, `[ x]`, `[ ]` — blanks/x/X but not the canonical + `[ ]`/`[x]`/`[X]`), and (b) more than one acceptance section (only the first is used). +- **Deliberately conservative** — `[1]` citations, `[-]` partial markers, and + `[text](url)` links are NOT flagged (a false positive would break lint-clean on legit + content). Verified the real corpus stays lint-clean. +- **Architecture:** `domain.LintAcceptanceCriteria(body)` (fence-aware, reuses the AC + scanner's model) wired through a new `ListTasksWithBodies` store scan — the task twin + of `ListAuditsWithFindings`, so lint reads every body ONCE (no O(N²) re-resolve). The + write path already guarded (out-of-range / no-section → exit 11); this closes the read + path. + +Tests: domain (malformed variants · no-false-positives · multiple sections · fence-aware +· out-of-section), store (`ListTasksWithBodies` carries bodies), core (Lint flags it via +the body scan), CLI (`lint --json` surfaces it end-to-end). Full suite + lint green. From bc06a66593076ae28cff772bf2472ae6d1947eec Mon Sep 17 00:00:00 2001 From: Andy Eschbacher Date: Thu, 16 Jul 2026 07:48:30 -0400 Subject: [PATCH 3/4] chore: update go major version --- .github/workflows/ci.yml | 2 +- .github/workflows/release.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d0847ad..57f2ff3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,7 +25,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v6 with: - go-version: "1.25" + go-version: "1.26" cache-dependency-path: go.sum - name: Download dependencies diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 25612a9..e145d2c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -32,7 +32,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v6 with: - go-version: "1.25" + go-version: "1.26" cache-dependency-path: go.sum - name: Run goreleaser From 8e9aba204c8d42801c58970bb8caf545dd94e3c4 Mon Sep 17 00:00:00 2001 From: Andy Eschbacher Date: Thu, 16 Jul 2026 08:13:42 -0400 Subject: [PATCH 4/4] chore(docs): planning updates --- ...-aware-body-mutation-and-metadata-reads.md | 26 ++++-- ...k-log-append-a-dated-progress-log-entry.md | 86 +++++++++++++++++++ 2 files changed, 107 insertions(+), 5 deletions(-) create mode 100644 planning/tasks/6fpnn6zk157b-task-log-append-a-dated-progress-log-entry.md diff --git a/planning/tasks/6fpfcecdymca-structure-aware-body-mutation-and-metadata-reads.md b/planning/tasks/6fpfcecdymca-structure-aware-body-mutation-and-metadata-reads.md index 77f4a1b..233a7bc 100644 --- a/planning/tasks/6fpfcecdymca-structure-aware-body-mutation-and-metadata-reads.md +++ b/planning/tasks/6fpfcecdymca-structure-aware-body-mutation-and-metadata-reads.md @@ -1,7 +1,7 @@ --- schema: 1 id: 6fpfcecdymca -status: in-progress +status: completed epic: 20-cli-ux-and-ergonomics description: 'Structure-aware body mutation + metadata reads (task log/ac, task path/info, show --section): the read-only cluster shipped' effort: Unknown @@ -12,6 +12,7 @@ tags: [cli, agents, ux, dx] created: "2026-07-15" started_at: "2026-07-15" updated_at: "2026-07-16" +completed_at: "2026-07-16" --- > ⚠️ **Externally proposed — filed 2026-07-15** from a second agent dogfooding > session — the sequel to @@ -70,10 +71,10 @@ so the next agent finds them without dogfooding-by-discovery. ## Acceptance criteria -- [ ] `task log --body|--body-file -` appends a dated bullet under - `## Progress Log`, creating the section only when absent — never a - duplicate header. Atomic; `--json` returns the task envelope; `--dry-run` - previews. +- [x] `task log` (item 1) **spun off** to its own task — + [task-log-append-a-dated-progress-log-entry](6fpnn6zk157b-task-log-append-a-dated-progress-log-entry.md) + — because it is blocked on the canonical progress-section decision, not just + unstarted. Tracked and designed there rather than holding this batch open. - [x] `task ac --list` numbers the criteria; `--check `/`--uncheck ` flip the box atomically, preserving surrounding body; `--json` supported. - [x] `task path ` prints the absolute file path and nothing else. @@ -277,3 +278,18 @@ guard so misconfiguration is loud, not a false positive: Tests: domain (malformed variants · no-false-positives · multiple sections · fence-aware · out-of-section), store (`ListTasksWithBodies` carries bodies), core (Lint flags it via the body scan), CLI (`lint --json` surfaces it end-to-end). Full suite + lint green. + +## Progress (2026-07-16) — batch complete; `task log` spun off + +Closing this batch. Three of the four items shipped and are on branch +`feat/various-read-modalities` (PR #106): `task ac` (item 2), `task path`/`info` +(item 3), and section reads (item 4) — plus the epic/audit parity and the +acceptance-criteria lint guard that grew out of review. + +Item 1 (`task log`) is **spun off** to +[task-log-append-a-dated-progress-log-entry](6fpnn6zk157b-task-log-append-a-dated-progress-log-entry.md): +it's blocked on the canonical progress-section-shape decision (single `## Progress +Log` + dated bullets vs this repo's per-entry `## Progress (date)` headings), so it +gets its own home with the decision + design captured, rather than holding this batch +open. This batch's responsibility — the structure-aware read/write surface — is +discharged. diff --git a/planning/tasks/6fpnn6zk157b-task-log-append-a-dated-progress-log-entry.md b/planning/tasks/6fpnn6zk157b-task-log-append-a-dated-progress-log-entry.md new file mode 100644 index 0000000..0de7e2f --- /dev/null +++ b/planning/tasks/6fpnn6zk157b-task-log-append-a-dated-progress-log-entry.md @@ -0,0 +1,86 @@ +--- +schema: 1 +id: 6fpnn6zk157b +status: ready-to-start +epic: 20-cli-ux-and-ergonomics +description: 'task log: append a dated progress entry (structure-aware body write); blocked on choosing the canonical progress-section shape' +effort: Unknown +tier: 3 +priority: medium +autonomy_level: 3 +tags: [cli, agents, ux, dx] +created: "2026-07-16" +--- +> ⚠️ **Spun off 2026-07-16** from +> [structure-aware-body-mutation-and-metadata-reads](6fpfcecdymca-structure-aware-body-mutation-and-metadata-reads.md) +> (item 1 of that batch). The other three items — `task ac`, `task path`/`info`, +> and the section reads — shipped; this one was parked because it needs a design +> decision *first*, so it gets its own home rather than holding the batch open. + +## Objective + +`task log --body "…"` (and `--body-file -`): append a **dated** entry to a +task's progress section — the single most frequent body write an agent makes. Today +`task append` is structure-blind: it re-declares `## Progress Log` when one already +exists, producing a duplicate header that then needs a hand-merge. `task log` should +(a) append under the EXISTING progress section, creating it only if absent, and +(b) auto-stamp today's date. + +## Decide first — the canonical progress-section shape + +`task log` can't be built until we settle what a "progress entry" looks like in THIS +repo, because the two conventions in play disagree, and hard-coding either fights the +other corpus: + +- **Single `## Progress Log` + dated bullets** (the requesting agent's repo): + + ``` + ## Progress Log + - 2026-07-16: shipped X + - 2026-07-17: shipped Y + ``` + + Compact; one section; `task log` appends a bullet. + +- **Per-entry dated headings** (this repo's de-facto corpus, incl. the batch task + this was spun off from): + + ``` + ## Progress (2026-07-16) + Shipped X. + + ## Progress (2026-07-17) + Shipped Y. + ``` + + Matches existing tasks; `task log` appends a new subsection. + +**The shape decision blocks implementation** — it is the first acceptance criterion. + +## Design notes (once the shape is decided) + +- Route the write through the existing `FS.EditBody` / surgical `yaml.Node` path (as + `task append` / `task set --body` / `task ac` do) so frontmatter, comments, key + order, and the parse-before-write + compare-and-swap discipline all survive. +- Reuse the fence-aware body-structure model in `internal/domain/body.go` + (`Section`, `scanAcceptanceCheckboxes`) — the same "structure as first-class" + muscle: locate the progress section, append under it, create if absent. Note the + precise-vs-substring lesson from the AC lint guard: a `## Progress …` heading that + mentions a keyword must not collide with section detection. +- Auto-stamp the date from the injected clock (`s.now()`), never `time.Now()`. + +## Acceptance criteria + +- [ ] The canonical progress-section shape is decided and recorded here. +- [ ] `task log --body|--body-file -` appends a dated entry under the existing + progress section, creating it only if absent — never a duplicate header. Atomic + (via `EditBody`); frontmatter preserved; date auto-stamped from the clock. +- [ ] `--json` returns the `task_mutation` envelope; `--dry-run` previews without + writing. +- [ ] Fence-aware (a `## Progress` inside a code block isn't the target); suite + lint + green; docs (`docgen`) + README updated. + +## Related + +- Spun off from [structure-aware-body-mutation-and-metadata-reads](6fpfcecdymca-structure-aware-body-mutation-and-metadata-reads.md). +- Epic [20-cli-ux-and-ergonomics](../epics/20-cli-ux-and-ergonomics.md).