Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,17 @@ field in place and stamp the dates atomically — no file moves (`lint --fix`
re-normalizes a hand-edited drift). Errors carry semantic exit codes — `10`
not-found, `11` validation, `13` ambiguous, `14` conflict (e.g. a name already taken).

**Task-dependency read foundation.** Task frontmatter and JSON may carry `depends_on`,
a sorted set of stable task IDs representing repository-global prerequisites. This
release reads, validates, and explains that graph but intentionally exposes no public
dependency mutation command yet. Generic task creation, `task set` (even `--force`),
`task edit`, and `lint --fix` cannot add, remove, or reinterpret dependency fields;
guarded `task depend add/remove` operations are the next slice. Ordinary `lint` reports
all graph defects. Exactly resolved legacy `blocked_by`/`dependencies`/`blocks` values
are visible JSON/human advisories with exit zero, while missing, ambiguous, cyclic, or
self-referential legacy projections remain validation errors. See
[`ADR-0006`](./planning/adrs/0006-adopt-threads-as-task-dags.md) for the model and rollout.

**Research** is the thinnest kind, and the omissions are the point: no status and
no lifecycle verbs (a later doc supersedes an earlier one — a decision that needs a
lifecycle is an [ADR](./planning/adrs/)), and no `epic:` field, so provenance is
Expand Down
13 changes: 12 additions & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,13 @@ adapter capabilities rather than leaked persistence.
adapter needs a complete entity service and watcher layout for an explicit start path.
Its `WorkspaceStore` port returns neutral capabilities; registry labels are carried as
presentation context and never influence discovery.
`TaskGraph` is an immutable read projection over one repository scan. It owns graph
health (`healthy`/`degraded`/`broken`), SCC-based cycle attribution, derived lifecycle
role and gate state, sound completion, topology, downstream impact, and separately
named causal-blocker and action-frontier projections. The analyzer uses only taskflow
types and owned deterministic algorithms; a graph package cannot leak into domain,
persistence, or wire contracts. Eligibility is read from derived state, never inferred
from an empty blocker list.
Per-space failures remain data in the projection; the CLI renders the complete sweep
before applying its partial-failure exit policy. Pure; unit-testable without fs.
- **`internal/store`** — the secondary adapter: tasks as
Expand All @@ -152,7 +159,11 @@ adapter capabilities rather than leaked persistence.
the use-case `Store`; CLI lint and the TUI watcher get the narrow
`Fixer`/`Linter`/`Layout` wired directly. It owns the *layout* knowledge — `WatchPaths()`
hands the TUI watcher its dir set so the path convention isn't reconstructed
outside the store. Concurrency is **version-CAS** (epic 24): every write, just
outside the store. Task dependency fields are graph-owned: generic create/set/edit
paths cannot introduce a semantic delta, and text-level lint repair skips a would-be
dependency normalization instead of manufacturing unchecked edges. The future guarded
dependency port will own the repository-wide read/validate/write critical section.
Concurrency is **version-CAS** (epic 24): every write, just
before committing, re-resolves the file by its **id** and re-hashes it
against the content read at the start of the op (`verifyUnchanged` in `cas.go` — a
strong whole-file SHA-256 computed on read, **never stored**), so a concurrent
Expand Down
2 changes: 1 addition & 1 deletion docs/cli/tskflwctl.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ Local-first planning CLI (tasks, epics, audits, research) over markdown
* [tskflwctl config](tskflwctl_config.md) - Inspect, migrate, diagnose, and edit configuration
* [tskflwctl epic](tskflwctl_epic.md) - Work with epics
* [tskflwctl init](tskflwctl_init.md) - Scaffold a planning tree here, or point at an external planning repo
* [tskflwctl lint](tskflwctl_lint.md) - Validate active task, epic, and research frontmatter (--fix repairs tasks/audits/research and assigns missing ids)
* [tskflwctl lint](tskflwctl_lint.md) - Validate entity frontmatter and task-dependency graph integrity
* [tskflwctl research](tskflwctl_research.md) - Work with research docs
* [tskflwctl schema](tskflwctl_schema.md) - Describe the tool's contract + per-kind authoring guidance (for agents)
* [tskflwctl space](tskflwctl_space.md) - Manage planning spaces and their registered entry points
Expand Down
14 changes: 12 additions & 2 deletions docs/cli/tskflwctl_lint.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,16 @@
## tskflwctl lint

Validate active task, epic, and research frontmatter (--fix repairs tasks/audits/research and assigns missing ids)
Validate entity frontmatter and task-dependency graph integrity

### Synopsis

Validate task, epic, and research frontmatter, then validate the repository-global
task-dependency graph. Exactly resolved legacy dependency fields are visible
advisories; missing, ambiguous, or structurally unsafe references are errors.

--fix repairs ordinary frontmatter and missing ids. It never normalizes or changes
graph-owned task fields (depends_on, blocked_by, dependencies, or blocks); a
would-be graph repair is skipped and reported for deliberate remediation.

```
tskflwctl lint [flags]
Expand All @@ -18,7 +28,7 @@ tskflwctl lint [flags]
### Options

```
--fix auto-repair frontmatter: quote ':' values, normalize lists, backfill missing task/audit/research ids; epics are text-only
--fix auto-repair ordinary frontmatter and missing ids; graph-owned task fields are skipped
-h, --help help for lint
--links also check body cross-links: flag any [..](path.md) whose target file is missing (opt-in — a tree can carry pre-existing danglers)
```
Expand Down
6 changes: 5 additions & 1 deletion docs/cli/tskflwctl_task_edit.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@ Open a task in your editor (whole file; re-validated on save)
Open the task's markdown file in $VISUAL/$EDITOR (falling back to vi). On
save the file is re-parsed: a frontmatter break (or a value the loader can't
read) reopens the editor with the error rather than landing on disk — deeper
field checks remain `lint`'s job. The human counterpart to `task set`; agents
field checks remain `lint`'s job. Graph-owned dependency fields are preservation-
only here: a semantic change is rejected, and a malformed dependency baseline
must be repaired deliberately before any edited candidate can land.

The human counterpart to `task set`; agents
and scripts should drive `set` (deterministic) instead.

```
Expand Down
7 changes: 7 additions & 0 deletions docs/cli/tskflwctl_task_set.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@

Set one or more frontmatter fields (validated, single atomic write)

### Synopsis

Set one or more task frontmatter fields in a single validated atomic write.
Graph-owned dependency fields (depends_on and the legacy blocked_by, dependencies,
and blocks fields) cannot be changed or removed here, including with --force.
They require the guarded dependency operations introduced by the dependency roadmap.

```
tskflwctl task set <task> [flags]
```
Expand Down
5 changes: 4 additions & 1 deletion internal/cli/edit.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,10 @@ func newTaskEditCmd(app *App) *cobra.Command {
Long: "Open the task's markdown file in $VISUAL/$EDITOR (falling back to vi). On\n" +
"save the file is re-parsed: a frontmatter break (or a value the loader can't\n" +
"read) reopens the editor with the error rather than landing on disk — deeper\n" +
"field checks remain `lint`'s job. The human counterpart to `task set`; agents\n" +
"field checks remain `lint`'s job. Graph-owned dependency fields are preservation-\n" +
"only here: a semantic change is rejected, and a malformed dependency baseline\n" +
"must be repaired deliberately before any edited candidate can land.\n\n" +
"The human counterpart to `task set`; agents\n" +
"and scripts should drive `set` (deterministic) instead.",
Example: " tskflwctl task edit add-retry-backoff\n tskflwctl task edit # pick from a list",
Args: cobra.MaximumNArgs(1), // bare → picker on a TTY; non-interactive needs the slug
Expand Down
23 changes: 16 additions & 7 deletions internal/cli/lint.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,21 @@ import (
"github.com/spf13/cobra"

"github.com/andy-esch/taskflow/internal/cli/render"
"github.com/andy-esch/taskflow/internal/core"
"github.com/andy-esch/taskflow/internal/domain"
)

func newLintCmd(app *App) *cobra.Command {
var fix, links bool
cmd := &cobra.Command{
Use: "lint",
Short: "Validate active task, epic, and research frontmatter (--fix repairs tasks/audits/research and assigns missing ids)",
Use: "lint",
Short: "Validate entity frontmatter and task-dependency graph integrity",
Long: "Validate task, epic, and research frontmatter, then validate the repository-global\n" +
"task-dependency graph. Exactly resolved legacy dependency fields are visible\n" +
"advisories; missing, ambiguous, or structurally unsafe references are errors.\n\n" +
"--fix repairs ordinary frontmatter and missing ids. It never normalizes or changes\n" +
"graph-owned task fields (depends_on, blocked_by, dependencies, or blocks); a\n" +
"would-be graph repair is skipped and reported for deliberate remediation.",
Example: " tskflwctl lint\n tskflwctl lint --fix --dry-run\n tskflwctl lint --links\n tskflwctl lint --json",
Args: cobra.NoArgs,
// Read-only by default; --fix opts into mutation explicitly.
Expand All @@ -25,7 +32,7 @@ func newLintCmd(app *App) *cobra.Command {
return runLint(app, links)
},
}
cmd.Flags().BoolVar(&fix, "fix", false, "auto-repair frontmatter: quote ':' values, normalize lists, backfill missing task/audit/research ids; epics are text-only")
cmd.Flags().BoolVar(&fix, "fix", false, "auto-repair ordinary frontmatter and missing ids; graph-owned task fields are skipped")
cmd.Flags().BoolVar(&links, "links", false, "also check body cross-links: flag any [..](path.md) whose target file is missing (opt-in — a tree can carry pre-existing danglers)")
return cmd
}
Expand Down Expand Up @@ -59,9 +66,10 @@ func runLint(app *App, links bool) error {
fmt.Fprintf(app.Out, "%s all active tasks and epics pass lint\n", app.Style.Green("✔"))
}
}
if len(results)+len(problems) > 0 {
blocking := core.BlockingLintResultCount(results)
if blocking+len(problems) > 0 {
return fmt.Errorf("%w: %d item(s) with issues, %d unreadable file(s)",
domain.ErrValidation, len(results), len(problems))
domain.ErrValidation, blocking, len(problems))
}
return nil
}
Expand Down Expand Up @@ -108,9 +116,10 @@ func runLintFix(app *App, dryRun bool) error {
render.FixHuman(app.Out, app.Style, results, results2, dryRun)
render.ProblemsHuman(app.ErrOut, app.Style, problems)
}
if len(results2)+len(problems) > 0 {
blocking := core.BlockingLintResultCount(results2)
if blocking+len(problems) > 0 {
return fmt.Errorf("%w: %d item(s) still with issues, %d unreadable file(s)",
domain.ErrValidation, len(results2), len(problems))
domain.ErrValidation, blocking, len(problems))
}
return nil
}
63 changes: 63 additions & 0 deletions internal/cli/lint_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,69 @@ func TestLint_Clean(t *testing.T) {
}
}

func TestLintReportsLegacyAndCanonicalDependencyDefects(t *testing.T) {
r := testutil.NewRepo(t)
r.Epic("01-e.md", "---\nstatus: active\npriority: high\ndescription: e\n---\n# E\n")
targetID := testutil.TaskID("target")
dependentID := testutil.TaskID("dependent")
selfID := testutil.TaskID("self")
r.Task("completed", "target.md", "---\nid: "+targetID+"\nstatus: completed\nepic: 01-e\n---\n# target\n")
r.Task("completed", "dependent.md", "---\nid: "+dependentID+"\nstatus: completed\nepic: 01-e\nblocked_by: [target]\n---\n# dependent\n")
r.Task("completed", "self.md", "---\nid: "+selfID+"\nstatus: completed\nepic: 01-e\ndepends_on: ["+selfID+"]\n---\n# self\n")

out, err := runRootRC(t, "-C", r.Root, "lint")
if err == nil || ExitCode(err) != 11 {
t.Fatalf("dependency defects must fail ordinary lint with exit 11, got %v", err)
}
for _, want := range []string{
"legacy dependency field", targetID, "guarded dependency operations",
"cannot depend on itself", "advisory finding",
} {
if !strings.Contains(out, want) {
t.Errorf("lint output missing %q:\n%s", want, out)
}
}
}

func TestLintResolvedLegacyDependencyIsAdvisoryWithExitZero(t *testing.T) {
r := testutil.NewRepo(t)
r.Epic("01-e.md", "---\nstatus: active\npriority: high\ndescription: e\n---\n# E\n")
targetID := testutil.TaskID("target")
dependentID := testutil.TaskID("dependent")
r.Task("completed", "target.md", "---\nid: "+targetID+"\nstatus: completed\nepic: 01-e\n---\n# target\n")
r.Task("completed", "dependent.md", "---\nid: "+dependentID+"\nstatus: completed\nepic: 01-e\nblocked_by: [target]\n---\n# dependent\n")

human, err := runRootRC(t, "-C", r.Root, "lint")
if err != nil {
t.Fatalf("safe legacy advisory must exit zero: %v\n%s", err, human)
}
if !strings.Contains(human, "legacy dependency field") || !strings.Contains(human, "1 advisory finding") {
t.Fatalf("human advisory output =\n%s", human)
}
jsonOut, err := runRootRC(t, "-C", r.Root, "lint", "--json")
if err != nil {
t.Fatalf("JSON advisory must exit zero: %v\n%s", err, jsonOut)
}
if !strings.Contains(jsonOut, `"severity":"advisory"`) {
t.Fatalf("JSON advisory severity missing:\n%s", jsonOut)
}
}

func TestLintUnsafeLegacyDependencyRemainsValidationError(t *testing.T) {
r := testutil.NewRepo(t)
r.Epic("01-e.md", "---\nstatus: active\npriority: high\ndescription: e\n---\n# E\n")
selfID := testutil.TaskID("self")
r.Task("completed", "self.md", "---\nid: "+selfID+"\nstatus: completed\nepic: 01-e\nblocked_by: [self]\n---\n# self\n")

out, err := runRootRC(t, "-C", r.Root, "lint", "--json")
if err == nil || ExitCode(err) != 11 {
t.Fatalf("unsafe legacy projection must exit 11: %v\n%s", err, out)
}
if !strings.Contains(out, "structurally unsafe") || strings.Contains(out, `"severity":"advisory"`) {
t.Fatalf("unsafe legacy output =\n%s", out)
}
}

// TestLint_FlagsNonNNEpicFailOpen pins the epic NN- gate end-to-end: a non-NN-<slug>
// epic is lint-flagged (exit 11, naming the convention) yet STILL lists/resolves — the
// fail-open contract, not a dropped FileProblem.
Expand Down
26 changes: 23 additions & 3 deletions internal/cli/render/render.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ package render
import (
"fmt"
"io"
"sort"
"strings"
"unicode"

Expand Down Expand Up @@ -93,6 +94,11 @@ func TaskShowHuman(w io.Writer, st Style, t domain.Task, body string) error {
if len(t.Tags) > 0 {
field("tags", strings.Join(t.Tags, ", "))
}
if len(t.DependsOn) > 0 {
deps := append([]string(nil), t.DependsOn...)
sort.Strings(deps)
field("depends on", strings.Join(deps, ", "))
}
if t.Description != "" {
field("description", t.Description)
}
Expand Down Expand Up @@ -810,14 +816,28 @@ func ProblemsHuman(w io.Writer, st Style, problems []domain.FileProblem) {
// entity for the footer ("task", "audit") since the same result/render shape backs
// both `lint` and `audit lint`.
func LintHuman(w io.Writer, st Style, results []core.LintResult, noun string) {
blockingItems, advisories := 0, 0
for _, r := range results {
fmt.Fprintf(w, "%s\n", st.Bold(r.Slug))
for _, iss := range r.Issues {
fmt.Fprintf(w, " %s %s\n", st.Red(iss.Field+":"), iss.Message)
field := st.Red(iss.Field + ":")
if !iss.Blocking() {
field = st.Warn(iss.Field + ":")
advisories++
}
fmt.Fprintf(w, " %s %s\n", field, iss.Message)
}
if r.Blocking() {
blockingItems++
}
}
if len(results) > 0 {
fmt.Fprintf(w, "\n%s\n", st.Dim(fmt.Sprintf("%d %s(s) with issues", len(results), noun)))
switch {
case blockingItems > 0 && advisories > 0:
fmt.Fprintf(w, "\n%s\n", st.Dim(fmt.Sprintf("%d %s(s) with issues · %d advisory finding(s)", blockingItems, noun, advisories)))
case blockingItems > 0:
fmt.Fprintf(w, "\n%s\n", st.Dim(fmt.Sprintf("%d %s(s) with issues", blockingItems, noun)))
case advisories > 0:
fmt.Fprintf(w, "\n%s\n", st.Dim(fmt.Sprintf("%d advisory finding(s)", advisories)))
}
}

Expand Down
8 changes: 6 additions & 2 deletions internal/cli/task.go
Original file line number Diff line number Diff line change
Expand Up @@ -507,8 +507,12 @@ func newTaskSetCmd(app *App) *cobra.Command {
force bool
)
cmd := &cobra.Command{
Use: "set <task>",
Short: "Set one or more frontmatter fields (validated, single atomic write)",
Use: "set <task>",
Short: "Set one or more frontmatter fields (validated, single atomic write)",
Long: "Set one or more task frontmatter fields in a single validated atomic write.\n" +
"Graph-owned dependency fields (depends_on and the legacy blocked_by, dependencies,\n" +
"and blocks fields) cannot be changed or removed here, including with --force.\n" +
"They require the guarded dependency operations introduced by the dependency roadmap.",
Example: " tskflwctl task set add-retry-backoff --priority high\n tskflwctl task set --priority high # pick the task from a list",
Args: cobra.MaximumNArgs(1), // bare → picker on a TTY; non-interactive needs the slug
Annotations: map[string]string{"safety": "mutating"},
Expand Down
2 changes: 1 addition & 1 deletion internal/cli/testdata/golden/audit_findings_json.golden
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{"schema_version":"1.48","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","status_decoration":"2026-01-03 (PR #1)"}]}
{"schema_version":"1.49","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","status_decoration":"2026-01-03 (PR #1)"}]}
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{"schema_version":"1.48","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.49","findings":[{"audit":"2026-01-02-fixture-area","bucket":"open","code":"S1","title":"Tighten the fixture gateway","status":"open","component":"fixturepipe","effort":"S","urgency":"soon"}]}
2 changes: 1 addition & 1 deletion internal/cli/testdata/golden/audit_info_json.golden
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{"schema_version":"1.48","audit_info":{"id":"6fjangd7kvh3","slug":"2026-01-02-fixture-area","bucket":"open","path":"<ROOT>/audits/6fjangd7kvh3-2026-01-02-fixture-area.md","findings":{"total":2,"open":1,"in_progress":0,"done":1,"dropped":0}}}
{"schema_version":"1.49","audit_info":{"id":"6fjangd7kvh3","slug":"2026-01-02-fixture-area","bucket":"open","path":"<ROOT>/audits/6fjangd7kvh3-2026-01-02-fixture-area.md","findings":{"total":2,"open":1,"in_progress":0,"done":1,"dropped":0}}}
2 changes: 1 addition & 1 deletion internal/cli/testdata/golden/audit_path_json.golden
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{"schema_version":"1.48","path":"<ROOT>/audits/6fjangd7kvh3-2026-01-02-fixture-area.md"}
{"schema_version":"1.49","path":"<ROOT>/audits/6fjangd7kvh3-2026-01-02-fixture-area.md"}
2 changes: 1 addition & 1 deletion internal/cli/testdata/golden/board_json.golden
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{"schema_version":"1.48","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.49","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"],"depends_on":["6fjangd7kvh2"]}]},{"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"]}]}]}
Loading