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
9 changes: 9 additions & 0 deletions cmd/tskflwctl/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,15 @@ func TestSmoke_LifecycleAndExitCodes(t *testing.T) {
if out, code := run(t, root, "task", "start", slug); code != 0 {
t.Fatalf("task start: exit %d\n%s", code, out)
}
// The scaffold's acceptance criterion is unticked and unexplained, so completing is
// refused (exit 11) — the task counterpart of `audit close` refusing while findings
// are open. Through a real process, so the gate is proven at the exit-code boundary.
if out, code := run(t, root, "task", "complete", slug); code != 11 {
t.Fatalf("complete with an unexplained criterion should exit 11, got %d\n%s", code, out)
}
if out, code := run(t, root, "task", "ac", slug, "--check", "1"); code != 0 {
t.Fatalf("task ac --check: exit %d\n%s", code, out)
}
if out, code := run(t, root, "task", "complete", slug); code != 0 {
t.Fatalf("task complete: exit %d\n%s", code, out)
}
Expand Down
12 changes: 11 additions & 1 deletion docs/cli/tskflwctl_task_complete.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,15 @@

Move task(s) to completed

### Synopsis

Move task(s) to completed.

Refuses a task whose acceptance criteria are still unmet with no reason given —
the task counterpart of `audit close` refusing while findings are open. A criterion
carrying a state (`task ac --defer|--wontfix|--tracked|--na`) has been DECIDED and
does not block; only a silently unticked box does. --force completes anyway.

```
tskflwctl task complete <task>... [flags]
```
Expand All @@ -16,7 +25,8 @@ tskflwctl task complete <task>... [flags]
### Options

```
-h, --help help for complete
--force complete even with unmet, unexplained acceptance criteria
-h, --help help for complete
```

### Options inherited from parent commands
Expand Down
2 changes: 1 addition & 1 deletion internal/cli/render/render.go
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@ func AcceptanceHuman(w io.Writer, st Style, cs []domain.Criterion) {
}
line := fmt.Sprintf("%s %s %s", mark, st.Dim(fmt.Sprintf("%2d.", c.Index)), c.Text)
if c.State.NeedsReason() {
line += " " + st.Warn(string(c.State)+":") + " " + st.Dim(c.Reason)
line += " " + st.CriterionState(string(c.State)) + st.Dim(": "+c.Reason)
}
fmt.Fprintln(w, line)
}
Expand Down
16 changes: 16 additions & 0 deletions internal/cli/render/style.go
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,22 @@ func (s Style) FindingStatus(status string) string {
return s.colorSeq(tok.Color) + tok.Glyph + " " + status + ansiReset
}

// CriterionState renders an acceptance criterion's state the way FindingStatus renders a
// finding's — glyph then word, in the state's own colour. The tokens come from
// theme.CriterionState, which delegates the shared words to the finding glyphs, so
// `◌ deferred` is the same mark on a criterion as on a finding.
//
// It replaced a single warn-coloured label used for every state: identical yellow for
// deferred, wontfix, and n/a, which is the second visual language criterion 8 of
// let-an-acceptance-criterion-say-more-than-done-or-not-done rules out.
func (s Style) CriterionState(state string) string {
if !s.on || state == "" {
return state
}
tok := theme.CriterionState(state)
return s.colorSeq(tok.Color) + tok.Glyph + " " + state + ansiReset
}

// Priority colors a priority label.
func (s Style) Priority(p string) string {
return s.wrap(s.colorSeq(theme.Priority(p)), p)
Expand Down
22 changes: 17 additions & 5 deletions internal/cli/task.go
Original file line number Diff line number Diff line change
Expand Up @@ -675,13 +675,14 @@ func newTaskMoveCmd(app *App) *cobra.Command {
if err != nil {
return err // already wraps ErrValidation and lists valid statuses
}
return runTransition(app, to, args[:len(args)-1])
return runTransition(app, to, args[:len(args)-1], false)
},
}
}

func newTransitionCmd(app *App, use, short string, to domain.Status) *cobra.Command {
return &cobra.Command{
var force bool
cmd := &cobra.Command{
Use: use + " <task>...",
Short: short,
Example: " tskflwctl task " + use + " my-task\n tskflwctl task " + use + " task-a task-b",
Expand All @@ -698,9 +699,20 @@ func newTransitionCmd(app *App, use, short string, to domain.Status) *cobra.Comm
}
args = []string{slug}
}
return runTransition(app, to, args)
return runTransition(app, to, args, force)
},
}
// Only `complete` has a gate to override — offering --force on `start` or `ready`
// would advertise a check that does not exist there.
if to == domain.StatusCompleted {
cmd.Long = short + ".\n\n" +
"Refuses a task whose acceptance criteria are still unmet with no reason given —\n" +
"the task counterpart of `audit close` refusing while findings are open. A criterion\n" +
"carrying a state (`task ac --defer|--wontfix|--tracked|--na`) has been DECIDED and\n" +
"does not block; only a silently unticked box does. --force completes anyway."
cmd.Flags().BoolVar(&force, "force", false, "complete even with unmet, unexplained acceptance criteria")
}
return cmd
}

// deprecatedTransitionCmd builds a hidden back-compat alias for a renamed verb:
Expand All @@ -715,9 +727,9 @@ func deprecatedTransitionCmd(app *App, oldVerb, newVerb string, to domain.Status
}

// runTransition moves each task to status `to`, via the shared runMoves report.
func runTransition(app *App, to domain.Status, slugs []string) error {
func runTransition(app *App, to domain.Status, slugs []string, force bool) error {
return runMoves(app, slugs, string(to),
func(slug string) (domain.Task, error) { return app.Svc.Move(slug, to, app.DryRun) },
func(slug string) (domain.Task, error) { return app.Svc.Move(slug, to, app.DryRun, force) },
func(t domain.Task) string { return t.Slug })
}

Expand Down
2 changes: 1 addition & 1 deletion internal/core/service_epic_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ func (nopStore) ListTasksWithBodies() ([]TaskWithBody, []domain.FileProblem, err
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) {
func (nopStore) Move(string, domain.Status, time.Time, bool, bool) (domain.Task, error) {
return domain.Task{}, nil
}
func (nopStore) Defer(string, string, time.Time, bool) (domain.Task, error) {
Expand Down
7 changes: 4 additions & 3 deletions internal/core/service_task.go
Original file line number Diff line number Diff line change
Expand Up @@ -169,12 +169,13 @@ func (s *Service) AppendBody(slug, text string, dryRun bool) (domain.Task, strin
}

// Move transitions a task to the given status (lifecycle engine behind the
// explicit verbs). Moving to the current status is an idempotent no-op.
// explicit verbs). Moving to the current status is an idempotent no-op. force bypasses
// the acceptance-criteria gate on a completion — see Store.Move.
// dryRun validates everything and returns the would-be task without writing.
func (s *Service) Move(slug string, to domain.Status, dryRun bool) (domain.Task, error) {
func (s *Service) Move(slug string, to domain.Status, dryRun, force bool) (domain.Task, error) {
now := s.now()
return retryOnConflict(s, dryRun, func() (domain.Task, error) {
return s.store.Move(slug, to, now, dryRun)
return s.store.Move(slug, to, now, dryRun, force)
})
}

Expand Down
4 changes: 3 additions & 1 deletion internal/core/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,9 @@ type TaskStore interface {
// 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.
Move(slug string, to domain.Status, now time.Time, dryRun bool) (domain.Task, error)
// force skips the acceptance-criteria gate on a move to completed (the task
// counterpart of MoveAudit's bucket↔state refusal).
Move(slug string, to domain.Status, now time.Time, dryRun, force bool) (domain.Task, error)
// Defer moves a task to deferred and, when until is non-empty, records it as
// revisit_at ("snooze until") in the SAME atomic write — so a deferred task can
// never be left without the snooze date it was deferred with (the lost-second-
Expand Down
114 changes: 101 additions & 13 deletions internal/domain/body.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,17 @@ type Criterion struct {
Suffix CriterionState
}

// 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.
// acCheckbox is an acceptance-criteria checkbox located in a body: the 0-based line
// index of its marker, the index of its LAST line, and current state/text.
//
// A criterion is not necessarily one line. The corpus wraps them, and treating only the
// marker line as the criterion truncated every wrapped one — `task ac --list`, `task show`
// and the JSON all showed "…rather than introducing a" and silently dropped the rest —
// while the state writer appended its suffix mid-sentence, leaving the remainder stranded
// on the line below. text is the criterion's full logical text with the wrapping collapsed.
type acCheckbox struct {
line int
line int // the marker line
end int // the criterion's last line (== line when it does not wrap)
checked bool
text string
}
Expand Down Expand Up @@ -157,12 +164,15 @@ func scanAcceptanceCheckboxes(body string) (lines []string, boxes []acCheckbox)
fence fenceScanner
inSection bool
sectionLvl int
open = -1 // index of the criterion still accepting continuation lines
)
for i, ln := range lines {
if fence.inCode(ln) {
open = -1
continue
}
if m := bodyHeadingRe.FindStringSubmatch(ln); m != nil {
open = -1
lvl := len(m[1])
switch {
case !inSection:
Expand All @@ -176,13 +186,33 @@ func scanAcceptanceCheckboxes(body string) (lines []string, boxes []acCheckbox)
}
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)})
boxes = append(boxes, acCheckbox{line: i, end: i, checked: m[1] == "x" || m[1] == "X", text: checkboxText(ln)})
open = len(boxes) - 1
continue
}
// A wrapped criterion continues on the following indented, non-list lines. A
// blank line, a new list item, or a heading ends it — the same rule a markdown
// reader applies, kept in this one pass so the fence tracker stays in step.
if open >= 0 && isCriterionContinuation(ln) {
boxes[open].end = i
boxes[open].text += " " + strings.TrimSpace(ln)
continue
}
open = -1
}
}
return lines, boxes
}

// isCriterionContinuation reports whether ln continues the criterion above it: indented,
// non-blank, and not itself a list item (a nested bullet is a sub-list, not more sentence).
func isCriterionContinuation(ln string) bool {
if strings.TrimSpace(ln) == "" || !strings.HasPrefix(ln, " ") && !strings.HasPrefix(ln, "\t") {
return false
}
return !acListItemRe.MatchString(ln)
}

// CountAcceptanceCriteria tallies the acceptance-criteria checkboxes. No such
// section — or none with checkboxes — yields a zero tally.
func CountAcceptanceCriteria(body string) ACCount {
Expand All @@ -201,6 +231,45 @@ func CountAcceptanceCriteria(body string) ACCount {
return c
}

// UnexplainedCriteria returns the criteria that are unmet AND say nothing about why —
// a bare unticked box. They are the ones that block `task complete`, and the distinction
// is the whole point of the state vocabulary: a criterion marked `wontfix` or `deferred`
// has been DECIDED, and a decision should not stand in the way of finishing a task. Only
// silence should.
func UnexplainedCriteria(body string) []Criterion {
var out []Criterion
for _, c := range ListAcceptanceCriteria(body) {
if c.State == CriterionUnmet {
out = append(out, c)
}
}
return out
}

// CriterionCount is one state's share of a task's acceptance criteria.
type CriterionCount struct {
State CriterionState
N int
}

// TallyCriteria counts a body's acceptance criteria by state, in the vocabulary's own
// order, omitting states with no members. It is the roll-up's source: a task with a
// criterion that is deferred rather than merely unticked has made a DECISION, and a bare
// "3/8" cannot say so — it reads as five things still to do.
func TallyCriteria(body string) []CriterionCount {
byState := map[CriterionState]int{}
for _, c := range ListAcceptanceCriteria(body) {
byState[c.State]++
}
out := make([]CriterionCount, 0, len(byState))
for _, st := range CriterionStates() {
if n := byState[st]; n > 0 {
out = append(out, CriterionCount{State: st, N: n})
}
}
return out
}

// 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 {
Expand Down Expand Up @@ -248,20 +317,39 @@ func SetCriterionState(body string, n int, state CriterionState, reason string)
return "", fmt.Errorf("%w: criterion %d out of range (have %d)", ErrValidation, n, len(boxes))
}
box := boxes[n-1]
line := lines[box.line]
// Strip whatever suffix is there before writing the new one, so repeated calls are
// idempotent rather than stacking markers.
text := checkboxText(line)
if stripped, _, _, ok := criterionSuffix(text); ok {
text = stripped // replace the existing disposition rather than stacking another
}
// Strip whatever suffix is there before writing the new one, so repeated calls replace
// rather than stack. It can sit on any line of a wrapped criterion — including the
// wrong one, if it was written before the writer knew criteria wrap.
for j := box.line; j <= box.end; j++ {
lines[j] = stripCriterionSuffixLine(lines[j])
}
lines[box.line] = replaceCheckboxLine(lines[box.line], state.Met(), strings.TrimSpace(checkboxText(lines[box.line])))
// The suffix belongs at the END of the criterion, which on a wrapped one is not the
// marker line: appending it there splits the sentence and leaves its tail dangling
// under the reason.
if state.NeedsReason() {
text = strings.TrimSpace(text) + fmt.Sprintf(" · **%s:** %s", state, strings.TrimSpace(reason))
lines[box.end] = strings.TrimRight(lines[box.end], " \t") +
fmt.Sprintf(" · **%s:** %s", state, strings.TrimSpace(reason))
}
lines[box.line] = replaceCheckboxLine(line, state.Met(), strings.TrimSpace(text))
return strings.Join(lines, "\n"), nil
}

// stripCriterionSuffixLine removes a trailing disposition suffix from one line, leaving a
// line that carries none untouched. Line-level rather than text-level because a wrapped
// criterion's suffix may be on a continuation line, which has no checkbox marker to parse.
func stripCriterionSuffixLine(line string) string {
stripped, _, _, ok := criterionSuffix(line)
if !ok {
return line
}
// criterionSuffix trims the whole text, indentation included. Re-attaching the line's
// own indent is what keeps a continuation line a continuation: strip it and the next
// scan no longer sees the line as part of the criterion, so the following write leaves
// its suffix behind and `met` stops clearing it.
indent := line[:len(line)-len(strings.TrimLeft(line, " \t"))]
return indent + strings.TrimRight(stripped, " \t")
}

// replaceCheckboxLine rebuilds one checkbox line, preserving its original indentation and
// bullet so a nested or differently-bulleted list survives a state change untouched.
func replaceCheckboxLine(line string, checked bool, text string) string {
Expand Down
Loading
Loading