Documentation
¶
Overview ¶
Package ticket implements the on-disk ticket format and store.
The file format IS the API: tickets are hand-edited and read in git diffs, so writing one field must leave every other byte of the file untouched. That constraint rules out the obvious implementation (unmarshal into a struct, mutate, marshal back), and it also rules out re-printing a YAML AST — both silently renormalize quoting, key order, and comment alignment on fields the caller never asked to change.
Instead, writes use the parser purely as a locator: it yields the line and column of the value being replaced, and the replacement is spliced into the original bytes. Everything outside the spliced span is preserved by construction rather than by the serializer's good behaviour.
Index ¶
- Constants
- Variables
- func AddItem(body string, sec Section, text string) (string, error)
- func AppendNote(d *Doc, text string, now time.Time, who string) error
- func CanonicalMode(v string) (string, bool)
- func CommitsSource(derived, merged []string) string
- func Counts() (decodes, headerParses int64)
- func CurrentRound(t *Ticket) int
- func DoDHeadings() []string
- func Filename(id, title string) string
- func FormatTime(t time.Time) string
- func Handle(id string) string
- func HeadingTitle(body string) string
- func IDFromFilename(name string) string
- func LogbookFolderDay(name string, loc *time.Location) (time.Time, bool)
- func LogbookWindowStart(now time.Time, days int) time.Time
- func MergeCommits(derived, recorded []string) []string
- func NewBody(title string, dod []string, options []BodyOption) string
- func NewID(now time.Time) string
- func NormalizeIDPrefix(p string) string
- func OptionHeadings() []string
- func PlanHeadings() []string
- func SetItemProof(body string, sec Section, i int, proof string) (string, error)
- func SetItemState(body string, sec Section, i int, st State) (string, error)
- func SetItemText(body string, sec Section, i int, text string) (string, error)
- func SetReady(d *Doc, ready bool) error
- func Slug(title string) string
- func Touch(d *Doc, now time.Time) error
- func TouchBy(d *Doc, actor string) error
- func ValidID(id string) bool
- func WithWorktree(source string) string
- func WriteAtomic(path string, data []byte) error
- type BodyOption
- type DoDItem
- type Doc
- func (d *Doc) Body() string
- func (d *Doc) Bytes() []byte
- func (d *Doc) Fields() (*Fields, error)
- func (d *Doc) Frontmatter() string
- func (d *Doc) Has(key string) bool
- func (d *Doc) Keys() []string
- func (d *Doc) List(key string) ([]string, error)
- func (d *Doc) Scalar(key string) (string, bool, error)
- func (d *Doc) SetBody(body string)
- func (d *Doc) SetList(key string, items []string) error
- func (d *Doc) SetRaw(key, literal string) error
- func (d *Doc) SetScalar(key, val string) error
- func (d *Doc) String() string
- func (d *Doc) Validate() error
- type Fields
- type FreshnessChecker
- type Logged
- type Note
- type Outcome
- type PartialError
- type Section
- type State
- type Store
- func (s *Store) Archive(id string) (string, error)
- func (s *Store) ArchiveDir() string
- func (s *Store) CheckFresh(t *Ticket) error
- func (s *Store) Create(fields map[string]string, lists map[string][]string, body string) (*Ticket, error)
- func (s *Store) Delete(id string) (string, error)
- func (s *Store) DuplicateIDs() []string
- func (s *Store) EnableCache()
- func (s *Store) FileLane(lane, folder string, prepare func(*Ticket) error) ([]Trimmed, error)
- func (s *Store) FiledAwayIDs() map[string]string
- func (s *Store) FiledMilestone(match func(base string) bool) (where, file string, ok bool)
- func (s *Store) Forget(paths ...string)
- func (s *Store) ForgetAll()
- func (s *Store) Init() (created bool, err error)
- func (s *Store) List() ([]*Ticket, error)
- func (s *Store) Load(idOrPrefix string) (*Ticket, error)
- func (s *Store) LoadAnywhere(idOrPrefix string) (*Ticket, string, error)
- func (s *Store) Lock(name string) (func(), error)
- func (s *Store) Logbook(id, folder string) (string, error)
- func (s *Store) LogbookDir() string
- func (s *Store) LogbookMilestone(file, folder string) (string, error)
- func (s *Store) LoggedPerDay(now time.Time, days int) []int
- func (s *Store) LoggedSince(now time.Time, days int) []Logged
- func (s *Store) MilestonesDir() string
- func (s *Store) Mutate(idOrPrefix string, fn func(*Ticket) error) (*Ticket, error)
- func (s *Store) Overflow(lane string, keep int, newest string) ([]*Ticket, error)
- func (s *Store) Paths() ([]string, error)
- func (s *Store) RepoStateDir() string
- func (s *Store) Restore(name string) (string, error)
- func (s *Store) Save(t *Ticket) error
- func (s *Store) SessionsDir() string
- func (s *Store) SharedDir() string
- func (s *Store) StampCommits(t *Ticket, derive func(*Ticket) []string) ([]string, error)
- func (s *Store) StateDir() string
- func (s *Store) TicketsDir() string
- func (s *Store) TrimLane(lane string, keep int, folder, newest string) ([]Trimmed, error)
- type Ticket
- type TicketSource
- type Trimmed
- type WriteRecorder
Constants ¶
const ( FieldID = "id" FieldTitle = "title" FieldStatus = "status" FieldReady = "ready" FieldCreator = "creator" FieldAssignee = "assignee" FieldGoal = "goal" FieldContext = "context" FieldDoD = "definition-of-done" FieldBlockedBy = "blocked-by" FieldCommits = "commits" FieldModelTier = "model-tier" FieldExecutedBy = "executed-by" FieldCreatedAt = "created-at" FieldUpdatedAt = "updated-at" // FieldUpdatedBy is who wrote the most recent change, beside when it // happened. Several people and several agent sessions write the same store, // and updated-at alone says a ticket moved under you without saying who // moved it — which is the thing you actually need before touching it. FieldUpdatedBy = "updated-by" FieldQuestion = "question" FieldClaimedBy = "claimed-by" // FieldMode says how this ticket is to be worked, not what it contains. // Empty is the default: lanes run autonomously. "conversational" says the // dispatcher found open design decisions on it and a person is reading // along — the worker shows its diff after each definition-of-done item and, // when its lane changed code, hands back a commit line instead of // committing itself. A lane that changed none hands back no line: the mode // sits on the ticket, so critique, testing and review run in it too, and // there the ordinary rule still holds — no code, no commit. // // It sits on the ticket rather than in the line that starts the worker so // that it survives a killed session: a fresh worker reads the mode off // disk, where a typed argument would be gone and the run would silently be // autonomous again. // // Nothing clears it automatically. The mode is a statement about the // ticket ("decisions were open here"), not about one lane, so it holds // through critique and testing too; a person clears it with // 'jaira set <id> mode='. FieldMode = "mode" FieldClaimedAt = "claimed-at" // The outcome is three flat keys rather than a nested mapping. Nesting would // force the writer to rewrite an indented block to change one field, and the // merge driver resolves per top-level key — flat keys keep both operations // single-line, which is the whole point of the format. FieldOutcomeWhat = "outcome-what" FieldOutcomeWhy = "outcome-why" FieldOutcomeResolves = "outcome-resolves" // Reserved for a future external tracker adapter. jaira never interprets it. FieldExternal = "external" // FieldFollows links a follow-up ticket back to the one whose review produced // it, so the trail from "this was not finished" to "this is the rest" is not // lost the moment the first ticket closes. FieldFollows = "follows" // FieldBlockedReason is why a ticket is parked in the blocked lane. blocked-by // covers the case where the blocker is another ticket; this covers everything // else — a vendor, a decision, an environment — which is the majority and // which the board otherwise records nowhere. A blocked ticket whose reason is // unrecorded is the one a board loses: it looks the same on day one and on // day ninety. FieldBlockedReason = "blocked-reason" // FieldReviewVerdict is the reviewer's conclusion, kept separate from // outcome-resolves. The implementer writes what it believes it achieved and // the reviewer judges the diff; storing both in one field destroys the pair // the sign-off view exists to show. FieldReviewVerdict = "review-verdict" // FieldReviewSummary is what the change actually does, read off the diff. // The verdict alone is a judgement with no account of what was judged; a // later reader needs the summary to know what shipped without re-reading // the diff themselves. FieldReviewSummary = "review-summary" // FieldReviewGaps is what the reviewer found missing or unconvincing. It // must be filled even when there is nothing to report — "none" is a valid // answer, an empty field is not — because an empty field must always mean // "nobody looked", never "nothing found". FieldReviewGaps = "review-gaps" // FieldReviewCheck is how to check the change by hand: run this, then that, // then look at this. Every other review field is an account of what // happened; this one is the only thing the reader can act on, and without it // signing off means either trusting the account or reconstructing the steps // from the diff. Written for someone who knows none of what the reviewer // knows — see the writing register the create and note helps carry. FieldReviewCheck = "review-check" // FieldRound counts the ticket's rounds: 1 until work is first sent back // to an earlier lane, one more with every send-back after (core/move). A // review field is only as current as the round it was written in, and the // loop lanes rewrite review-gaps every round while review-verdict waits for // the review: without a count nobody can tell whether the two belong // together. Absent means 1, so a ticket from before the field is round 1. FieldRound = "round" // FieldReviewRound is the round review-verdict was written in, stamped by // the write itself. Absent means unknown — a verdict from before the field // — and a reader treats the verdict as it always did. FieldReviewRound = "review-round" // FieldParent is the ticket this one is a part of — the child names the // parent, never the other way round. A parent field on the child is the // only direction that can always be written: the parent may be in the // logbook, in the archive, or on a ref with no file here at all, and a // children list would mean editing a file that does not exist. It also // keeps one writer per file, which is what stops two sessions adding two // children from colliding on one line. // // Children and the whole tree below them are derived by reading, not // stored. Nesting is free that way, and there is no second copy of the // relation to drift. // // parent is deliberately not a gate: an unfinished child does not hold its // parent back. A dependency that must hold is blocked-by. FieldParent = "parent" // FieldRelated is a list of tickets this one merely has to do with — // neither blocking it nor containing it. It is stored on whichever side // somebody happened to edit and shown from both, because a relation that // had to be written twice would be half-written most of the time. FieldRelated = "related" // FieldTags is a plain list of topic labels — "ui", "backend" — so a // backlog can be read one subject at a time instead of one lane at a time. // A list rather than a comma-joined scalar because that is what every other // multi-valued field here is, which is what makes 'jaira set tags=a,b' and // the merge driver's union-instead-of-pick behaviour come for free. // // Colours are deliberately not here: they live once per board in .jaira/tags // (core/tag), because a colour is a fact about the tag rather than about the // ticket wearing it. FieldTags = "tags" )
Frontmatter field names. These are the API — they appear in every ticket file and in git diffs, so they are stable and referenced by name everywhere rather than being spelled inline.
const ( // DirName is the per-repo directory holding all jaira state. DirName = ".jaira" // TicketsSubdir holds one markdown file per ticket. TicketsSubdir = "tickets" // ArchiveSubdir holds tickets taken off the board. They are moved rather // than deleted: the whole point of the board is that you can still answer // what a task was for months later, and a deleted file on a private board // is not in git history to recover from. ArchiveSubdir = "archive" // LanesSubdir it is committed: publishing a lane is an opt-in act through // the lane settings screen, not a side effect of sharing the board. SharedSubdir = "shared" // LogbookSubdir holds tickets whose work is finished and whose commit list // is final, grouped into dated folders by who took them off the board. A // folder under here is a readable record of one person's sweep — unlike // ArchiveSubdir, every ticket that lands here has already had its commits // stamped, because leaving the board is the point at which every commit // is finally known. LogbookSubdir = "logbook" // MilestonesSubdir holds one file per milestone, and names the folder a // filed milestone gets inside a logbook folder. It is spelled here rather // than taken from core/milestone because that package reads this one; the // constant there is defined from this one, so there is still one spelling. MilestonesSubdir = "milestones" // SessionsSubdir and locksSubdir live under the user's home directory rather // than inside the repository. tickets/, archive/ and shared/ are the parts of // .jaira/ meant to be committed; lanes/ (see core/lane.ProjectLanesDir) is // this machine's own scoping and stays gitignored even on a shared board // (see core/board.LanesIgnoreLine); sessions and locks stay out of the // repository entirely. SessionsSubdir = "sessions" )
const ModeConversational = "conversational"
ModeConversational is the one non-empty value FieldMode takes. A worker compares against exactly this word, which is why 'jaira set' refuses anything else: "mode=chat" would be stored happily and do nothing, and a person believing they are in the mode while the agent commits is the failure this mode exists to prevent.
const NoteHeading = "## Progress"
NoteHeading is the body section progress notes live under.
Notes go in the ticket body rather than a sidecar file on purpose: a separate memory file would be one more thing to lose, would not travel with the ticket through git, and could drift out of sync with it.
const SourceWorktree = "worktree"
SourceWorktree is the token CommitsSource cannot produce, because it names something that is not a commit: work sitting in the working tree, judged alongside the commits. It joins a commit source with a "+", so a list built from git with uncommitted work beside it reads "git+worktree".
Variables ¶
var ( // ErrNoFrontmatter means the file does not open with a YAML frontmatter block. ErrNoFrontmatter = errors.New("ticket: file does not begin with '---'") // ErrUnterminated means the frontmatter block is never closed. ErrUnterminated = errors.New("ticket: frontmatter block is not terminated by '---'") // ErrUnsafeYAML means the frontmatter uses constructs this tool cannot // rewrite without risking corruption. Reported rather than mangled. ErrUnsafeYAML = errors.New("ticket: frontmatter uses YAML features jaira cannot safely rewrite") )
var ( // ErrNotFound means no ticket matched. ErrNotFound = errors.New("ticket: not found") // ErrAmbiguous means an ID prefix matched more than one ticket. ErrAmbiguous = errors.New("ticket: ambiguous id prefix") // ErrNoStore means no .jaira directory was found. ErrNoStore = errors.New("ticket: no .jaira directory found; run 'jaira init'") )
var ErrNotRecorded = errors.New("ticket: the write was not recorded for the remote")
ErrNotRecorded means the local write went through but the recorder did not take it — the ticket is correct on this machine and has not left it.
It is a separate sentinel rather than a plain error because the two halves need opposite handling by the caller: the mutation must be reported as done (it is), while the failure to hand it on is worth a line to the user. A recorder that returns nothing but this is still a working board.
var ErrOnRefOnly = errors.New("ticket: on its ref and not on your disk; pull it first")
ErrOnRefOnly means the ticket exists for this board but not as a file here, so it cannot be written to until somebody pulls it.
It is separate from ErrNotFound because the two ask different things of the user: one means the id is wrong, the other means the ticket is real and one command away.
var ErrStale = errors.New("ticket: the file on your disk is older than its ref; pull it first")
ErrStale means the ticket file on this disk is older than the ticket's ref. Writing it would put the old state back on the ref, over everything the team recorded since — which is how a review, its proofs and nine notes once left a ticket in one 'move --force'.
var SuppliedFields = []string{ FieldTitle, FieldGoal, FieldContext, FieldDoD, FieldAssignee, "diff", "notes", }
SuppliedFields names the lane input-requires values a ticket already carries — or that the tool assembling a lane's bounded input derives itself — from the moment of creation, independent of any lane's output-produces. "diff" is not a frontmatter scalar (it is assembled from git commits at request time), so it is named here rather than with a Field constant.
"plan" is deliberately not in this set, even though the Plan heading is seeded on every new ticket: an empty Plan checklist does not satisfy a lane's input-requires (see flow.go's showForLane), so a lane requiring "plan" genuinely depends on an earlier lane — pre-process — having produced one. Exempting it here would make it impossible for the load-order check below to ever catch a lane misordered relative to its own producer, which is the one case this check exists for.
core/lane's load-time contract check compares a lane's input-requires against this set plus every earlier lane's output-produces, so this is the one place both packages read rather than two lists that could drift apart.
Functions ¶
func AddItem ¶
AddItem appends a checklist item to a section, creating the section if the ticket has no such heading.
This exists because there was no way to write a plan at all: frontmatter is set with SetScalar and the body was only editable by hand, so the lane whose entire output is a Plan checklist had no means to produce one. Appending to the end of the file is not the same thing — a checkbox only counts inside its heading, and the end of the file is usually some other section.
func AppendNote ¶ added in v0.1.3
AppendNote adds one timestamped note under the Progress heading, creating the heading if the body has none.
It lives here rather than in the command that types notes because it now has two callers — a person writing one, and a take-over recording that it happened — and two copies of "where does a note go" would eventually disagree about the answer.
func CanonicalMode ¶ added in v0.3.0
CanonicalMode reports whether v is a value FieldMode accepts — empty, meaning the default autonomous run, or ModeConversational — and returns the form to store. Every write path goes through here rather than comparing against the constant itself, so the CLI and the TUI cannot come to disagree about what a mode is. It returns the trimmed value rather than only judging it, because a path that accepts " conversational " and then stores it unchanged is the same silent failure as accepting "chat": the worker compares against exactly one word and would run on as if no mode were set.
func CommitsSource ¶ added in v0.3.1
CommitsSource names where a merged commit list came from: "git" when the derivation accounts for all of it, "ticket" when the derivation found nothing and the recorded field carries it, and "git+ticket" when the field contributed a sha git could not find — a rebase or a cherry-pick. Every screen that labels such a list shares this so two screens built on the same data cannot end up holding themselves to two standards of honesty.
func Counts ¶ added in v0.5.0
func Counts() (decodes, headerParses int64)
Counts reports how many tickets decodeFields has built and how many ticket frontmatters have been parsed, since the process started. Tests read the difference across an operation to prove it did no work.
func CurrentRound ¶ added in v0.4.0
CurrentRound is the ticket's round: 1 until a send-back first counted one.
func DoDHeadings ¶
func DoDHeadings() []string
DoDHeadings and PlanHeadings expose the recognised section names so a renderer can tell which parts of a body it has already displayed as a checklist.
func FormatTime ¶
FormatTime renders a timestamp the way tickets store it.
func Handle ¶
Handle is the short reference shown to people and printed by the CLI.
It is taken from the end of the ULID rather than the beginning, because the leading ten characters encode only the millisecond timestamp: tickets created in the same burst share them entirely. The trailing characters are the random component, so a tail is distinctive where a head is not.
func HeadingTitle ¶
HeadingTitle returns the first level-one heading of a markdown body.
func IDFromFilename ¶
IDFromFilename recovers the identifier from a ticket filename.
func LogbookFolderDay ¶ added in v0.3.2
LogbookFolderDay reads the day a logbook folder was filed on off its name, <initials>-<yyyymmdd>, and reports false for a name that does not end in one.
func LogbookWindowStart ¶ added in v0.3.4
LogbookWindowStart is the first day a logbook window of days days reaches back to: midnight, days days before now. A folder dated that day or later is inside the window. Whole days, because a folder carries a day and nothing finer; time.Date rather than Add, because across a DST change a day is not 24 hours. 'jaira logbook' and the board cut at the same place through this.
func MergeCommits ¶ added in v0.3.1
MergeCommits unions a derived commit list with a recorded one. Derived shas come first, in git order; a recorded sha the derivation did not find is appended rather than dropped — a sha somebody wrote down deliberately, or one whose commit a rebase moved off the branch, is evidence this tool has no business discarding. Four callers share it and must not drift apart: StampCommits, which writes the union onto the ticket; 'move --out --commits' (internal/cli/flow.go), which folds the shas a lane hands back into the field; the lane payload (same file), which reads it to build the diff a review lane judges; and the signoff screen (internal/tui/signoff.go), the screen a person accepts work on, where drift costs most.
func NewBody ¶
func NewBody(title string, dod []string, options []BodyOption) string
NewBody is the starting shape of a ticket's markdown, shared by the CLI and the TUI so a ticket created either way looks the same.
options is resolved by the caller (core/ticket must not import core/lane, which itself imports core/ticket) from the installed lanes' requires-option fields, with a per-user default board deciding which ones start ticked. Passing nil options reproduces today's two-option shape with nothing ticked — the regression baseline this function must not silently drift from.
func NewID ¶
NewID mints a ticket identifier.
ULIDs are used rather than sequential numbers (JAIRA-1, JAIRA-2, ...) because a sequential scheme needs a coordinator to hand out the next value. In a store with no server, that coordinator would be a counter file — the single line in the repository that every ticket creation touches, and therefore the worst possible merge-conflict hotspot. A ULID needs no coordinator, and it sorts lexicographically by creation time, so the tickets directory lists in order.
func NormalizeIDPrefix ¶
NormalizeIDPrefix upper-cases a user-supplied prefix, since ULIDs are encoded in uppercase Crockford base32 but nobody types them that way.
func OptionHeadings ¶
func OptionHeadings() []string
func PlanHeadings ¶
func PlanHeadings() []string
func SetItemProof ¶
SetItemProof attaches evidence to the i-th checkbox (0-based) of one section, replacing any proof line already there, and returns the new body.
The proof rides directly beneath its item rather than living in its own section: that is what lets it survive SetItemState and AddItem acting on other items, since neither touches a line it does not address by index. The item's own line is left byte-identical, the same way SetItemState leaves it — the point of a one-character or one-line diff applies here too.
func SetItemState ¶
SetItemState rewrites the marker of the i-th checkbox (0-based) of one section and returns the new body.
Only the single marker character is replaced. The item's text, the surrounding prose, and every line ending are left exactly as they were, so a tick produces a one-character diff rather than a reflowed body — which is what keeps the file readable in a diff and cheap to merge.
Marking an item as in progress clears any other in-progress item in the same section: the marker answers "which one is being worked on", and that question has one answer.
func SetItemText ¶ added in v0.1.1
SetItemText rewrites the words of the i-th checkbox (0-based) of one section, leaving its marker and its proof line exactly as they were.
Without this there was no way to reword a criterion at all: the only route was to tick the stale one with the proof "obsolete, replaced by item 6" and append a new one, which leaves ticked items that were never met. Rewording is the honest form of that edit; SetItemState with StateSuperseded is the other.
func SetReady ¶
SetReady records whether the promotion gate is currently satisfied. It is a derived convenience for rendering, never the authority: the gate is always recomputed before a move.
func Slug ¶
Slug renders a title into a filename-safe breadcrumb. It is a static hint for humans running ls or grep, deliberately not kept in sync with later title edits: renaming files on every title change would churn git history for no benefit. The frontmatter title is the only field callers should trust.
func Touch ¶
Touch stamps updated-at. Every mutation path calls this so recency-based conflict resolution has something to work with.
func TouchBy ¶ added in v0.1.1
TouchBy records who made a change, beside when Touch records that it happened. An empty actor writes nothing: a caller that does not know who it is must not claim the change was nobody's.
func ValidID ¶
ValidID reports whether id is a well-formed ULID.
Every reference between tickets — dependencies, follow-ups, the handle shown on a card — resolves through the id, so an id that does not parse makes the ticket unreachable rather than merely untidy.
func WithWorktree ¶ added in v0.3.1
WithWorktree appends SourceWorktree to a source token. The vocabulary lives here, beside CommitsSource, for the reason CommitsSource itself does: two screens inventing their own spelling of the same fact is how one of them ends up quietly telling a different truth.
func WriteAtomic ¶ added in v0.1.1
WriteAtomic writes via a temporary file in the same directory and renames it into place, so a crash or a full disk leaves the previous file intact rather than a truncated one (STORE-06).
Exported because the ticket files are not the only thing in .jaira that two sessions write at once: the tag registry (core/tag) needs the same guarantee, and a second copy of this dance is a second place for it to be subtly wrong.
Types ¶
type BodyOption ¶
BodyOption is one entry in a new ticket's Options checklist.
type DoDItem ¶
type DoDItem struct {
Text string
State State
// Proof is the evidence recorded for this item, written by SetItemProof and
// read back off the plain indented line directly beneath the item's own —
// never its own checkbox, since a checkbox there would be misread as
// another criterion to satisfy rather than support for this one.
Proof string
}
DoDItem is one checkbox of a checklist, in either the Plan or the definition of done.
func ParseDoDItems ¶
ParseDoDItems extracts the checkboxes under a Definition of Done heading.
Only that section is read: checkboxes elsewhere in a ticket (an open-questions list, say) are not acceptance criteria and must not be mistaken for them.
func ParseOptions ¶
ParseOptions extracts the checkboxes under an Options heading.
func ParsePlanItems ¶
ParsePlanItems extracts the checkboxes under a Plan heading.
The Plan is how the work is being done — write the spec, design it, implement it — and carries the in-progress marker. It deliberately does not gate the terminal lane: following a method is not the same as having met the criteria.
func (DoDItem) Checked ¶
Checked reports whether this item is finished. Only StateDone counts: an item in progress is outstanding work, and a superseded one was never done at all.
type Doc ¶
type Doc struct {
// contains filtered or unexported fields
}
Doc is a ticket file split into the three zones that get different treatment: the opening delimiter, the frontmatter body (the only part YAML ever sees), and the markdown body (never parsed, only carried).
func NewDoc ¶
NewDoc builds a fresh ticket document with fields in canonical order. Only non-empty fields are written, so a new ticket file stays readable rather than being padded with empty keys.
func (*Doc) Fields ¶ added in v0.5.0
Fields parses the frontmatter once. The result reads only; it does not follow later writes to the Doc.
func (*Doc) Frontmatter ¶
Frontmatter returns the raw frontmatter zone, for callers that want to unmarshal it normally. Reads do not need byte fidelity, so unmarshalling here is safe; only writes must go through the splice path.
func (*Doc) SetList ¶
SetList sets a top-level list field. The whole block for that key is replaced (it necessarily spans multiple lines), but no other key is disturbed. A trailing comment on the key line is carried over.
func (*Doc) SetRaw ¶
SetRaw sets a field to a literal YAML scalar without quoting it. Used for values that are unambiguous by construction — booleans and RFC3339 timestamps — so tickets read idiomatically instead of carrying `ready: "false"`. Callers are responsible for passing something valid; anything user-supplied should go through SetScalar, which quotes defensively.
func (*Doc) SetScalar ¶
SetScalar sets a top-level scalar field. An existing value is spliced in place, preserving the line's indentation, quoting style and trailing comment. A missing key is appended at the end of the frontmatter.
func (*Doc) Validate ¶
Validate rejects frontmatter containing constructs the splice writer cannot safely round-trip. Anchors and aliases are the realistic hazard: a hand-edited ticket using them could be silently restructured by a naive rewrite, so the tool refuses to touch such a file rather than risking corruption (STORE-10).
type Fields ¶ added in v0.5.0
type Fields struct {
// contains filtered or unexported fields
}
Fields is one parse of a frontmatter block. Reading many keys through it costs one parse where the same reads through Doc cost one each.
type FreshnessChecker ¶ added in v0.3.7
type FreshnessChecker interface {
// CheckFresh returns an error wrapping ErrStale when what the ticket's ref
// says is newer than the file t was read from, and nil otherwise —
// including when there is no ref to compare with.
CheckFresh(t *Ticket) error
}
FreshnessChecker is what a Recorder implements when it can tell a stale file from a current one. It is a separate, optional interface because Record runs after the write and must never veto it; this runs before.
type Logged ¶ added in v0.3.4
Logged is a ticket lying in the logbook, with the day its folder was filed.
type Note ¶ added in v0.5.1
type Note struct {
At time.Time
Author string
// Text is everything after the heading, continuation lines included,
// verbatim: it is markdown a person wrote.
Text string
// Raw is the whole entry, heading included, as it stands in the body.
Raw string
}
Note is one progress note as AppendNote wrote it.
func Notes ¶ added in v0.5.1
Notes reads the progress notes back out of a body, oldest first, the order AppendNote adds them in. Text before the first note is not a note, and the section ends at the next heading. A dated entry written by hand in another shape still counts as a note; it just has no author, and its Text is Raw.
type Outcome ¶
Outcome is the three-part close-out: what changed, why it was needed, and the causal link back to the Definition of Done. The third field exists because "what changed" alone does not let a reviewer judge whether the work is done.
type PartialError ¶
type PartialError struct{ Problems []string }
PartialError reports tickets that could not be read while others succeeded.
func (*PartialError) Error ¶
func (e *PartialError) Error() string
type Section ¶
type Section int
Section names one of the two checklists a ticket can carry.
const ( // SectionDoD is the definition of done: the criteria that decide whether // the work is acceptable. Only this one gates the terminal lane. SectionDoD Section = iota // SectionPlan is the method being followed to get there. SectionPlan // SectionOptions names the steps this ticket does and does not need. SectionOptions )
type State ¶
type State int
State is how far one checklist item has got.
const ( // StateTodo is an item not started, or one whose marker is not recognised. // An unknown marker is read as unfinished rather than skipped: dropping it // would shorten the list and make the checklist easier to complete than the // author wrote it. StateTodo State = iota // StateDoing is the item currently being worked on, written as "[~]". It is // progress, not a completion claim, so it does not satisfy the criterion. StateDoing // StateDone is a finished item, written as "[x]". StateDone // StateSuperseded is an item that did not happen and will not, written as // "[-]". It is not a quieter [x]: the work was never done, so nothing may // report it as achieved. It exists because the only way to retire a // criterion used to be ticking it with the proof "obsolete, replaced by item // 6" — which leaves ticked items that were never met, and a tick that can // mean "never happened" means nothing anywhere on the board. StateSuperseded )
type Store ¶
type Store struct {
// Root is the directory containing .jaira.
Root string
// Actor is who this process writes as, recorded on every mutation as
// updated-by. It is a field rather than a package-level value because a
// process can hold two stores at once — the board switcher does — and it is
// set by the caller that already knows the identity, so core/ticket does
// not have to depend on core/identity. Empty records nothing.
Actor string
// Recorder is told about every ticket write, so a ticket can also travel
// on a git ref of its own and reach someone who does not have the writer's
// branch. It is an interface set by the caller for the same reason Actor
// is a field: core/ticket must not know about git, refs or queues, and the
// package that does (core/refsync) needs to read tickets. Nil records
// nothing, which is the case for every board without a remote.
Recorder WriteRecorder
// Source supplies tickets this board can see but has no file for — a
// ticket that is still travelling on its own git ref and has not been
// pulled into work here.
//
// It is the mirror of Recorder, and it exists for the same reason: every
// reader in this program goes through List and Load, and a reader that had
// to remember to ask a second place would be a reader that silently shows
// half the board. core/ticket stays unaware of git; the package that knows
// (core/refsync) fills this in.
Source TicketSource
// contains filtered or unexported fields
}
Store is the ticket directory for one repository.
func (*Store) Archive ¶
Archive moves a ticket out of the board, returning its new path.
The file is moved, never removed. Restoring is moving it back, which is why this returns the destination rather than swallowing it.
func (*Store) ArchiveDir ¶
ArchiveDir is where archived tickets live.
func (*Store) CheckFresh ¶ added in v0.3.7
CheckFresh refuses a ticket whose file is older than its ref. Mutate calls it on every write; a caller that judges the ticket before writing — the gate in a move — calls it first, so the refusals it reads are not the stale file's.
func (*Store) Create ¶
func (s *Store) Create(fields map[string]string, lists map[string][]string, body string) (*Ticket, error)
Create writes a new ticket file and returns it.
func (*Store) Delete ¶ added in v0.1.1
Delete removes a ticket's file and returns the path it was at.
The only irreversible operation in the store, and it exists because archiving is the wrong answer for a ticket that should never have been written: a mistyped create or a throwaway probe leaves a file that otherwise only 'rm' removes, and 'rm' means the caller has to know the file layout. Whether the caller is sure is decided above this line — the CLI asks for the handle typed back, and the board asks for it again.
Symlinks are resolved for the same reason Archive resolves them: deleting the link alone would leave the ticket on disk but off the board. Both go, since a half-deleted ticket is worse than either outcome.
func (*Store) DuplicateIDs ¶
DuplicateIDs reports ids claimed by more than one file, discovered during the most recent lookup.
func (*Store) EnableCache ¶ added in v0.5.0
func (s *Store) EnableCache()
EnableCache makes List, LoggedSince and the id lookup skip a ticket file whose modification time and size are unchanged since it was last decoded. It is off until called: a one-shot CLI process has nothing to remember, and only the board, which lists every 2 seconds, gains from it. Load is never cached, since it is the path a write starts from.
func (*Store) FileLane ¶ added in v0.1.1
FileLane empties a lane into the given logbook folder, oldest first. Two callers reach it: a doorway lane (logbook-on-entry), where the just-landed ticket goes along with anything still sitting there from before, and 'jaira logbook --all', the cut somebody makes by hand. Both take what filesInLane hands them, so ref-only tickets stay where they are. prepare runs on each ticket before its file moves (the callers stamp commits there); nil skips it. A ticket that cannot be filed — unreadable, a stamp failure, a name collision in the folder — is skipped and named rather than allowed to jam the doorway: a sweep aborts on nothing, because the same failure would repeat on every later landing and the lane would block for good. What moved and what was skipped are both always reported.
func (*Store) FiledAwayIDs ¶ added in v0.1.3
FiledAwayIDs maps the ids of tickets filed away here — logged or archived — to the file that holds them.
Exported because two questions need it and neither belongs in this package: whether a ticket is on the board and filed away at once, and whether a finished ticket has arrived anywhere everybody can see it.
func (*Store) FiledMilestone ¶ added in v0.3.0
FiledMilestone reports whether a milestone lies filed in the logbook, and where, as a path relative to the repository root, and under which file name — the one 'jaira restore' takes. match is asked the base name of every file there, without .md, because what a file is called is the caller's to read: this package does not know that a milestone file may carry an ID.
It looks in every folder Restore looks in — including the one the logbook was called before it was one — because a milestone Restore can still bring back is one the commands that need it on the board should point at restore for. A hand-rolled walk over LogbookDir alone misses that older folder and answers "no such milestone" for it.
func (*Store) Forget ¶ added in v0.5.0
Forget drops the cached headers of these paths, so the next read decodes them again. A path that was never cached, or any call with the cache off, is a no-op.
func (*Store) ForgetAll ¶ added in v0.5.0
func (s *Store) ForgetAll()
ForgetAll drops every cached header.
func (*Store) List ¶
List loads every ticket, reading only as much of each file as the frontmatter requires.
func (*Store) LoadAnywhere ¶ added in v0.1.4
LoadAnywhere reads one ticket from wherever it still exists: the board first, then the logbook and the archive.
Load deliberately sees only the board, because every write path goes through it and a filed ticket must not be writable by accident. Reading is the opposite case: a ticket the board points at — a blocker, a parent, a follow-up — keeps being asked about long after it was filed, and answering "not found" for a file sitting in plain sight under .jaira/logbook/ is what made every link rot the moment the work behind it finished.
The second result is the file it was read from, so a caller can say where it lives rather than implying it is still on the board.
func (*Store) Lock ¶ added in v0.1.1
Lock takes the store's advisory lock under a caller-chosen name, for state in .jaira that is not one ticket.
The name shares one namespace with the per-ticket locks, which are keyed by id, so any name that is not a 26-character ULID is safe — "tags" is not one. A name that IS a ULID would silently take that ticket's lock, and nothing here checks for it: the callers are in this repository, not a plugin surface.
The caller must call the returned function, normally with defer. Failure to acquire within the timeout is an error, not a silent write.
func (*Store) Logbook ¶ added in v0.1.1
Logbook moves a ticket out of the board and into a dated logbook folder, returning its new path. Like Archive, the file is moved rather than deleted, and a name collision inside the destination folder is refused by name rather than silently overwritten — this only happens if the same ticket is logged into the same folder twice, which a caller should treat as a bug to look into, not a state to paper over.
func (*Store) LogbookDir ¶ added in v0.1.1
LogbookDir is where tickets taken off the board with their commits stamped live, grouped into dated per-person folders.
func (*Store) LogbookMilestone ¶ added in v0.3.0
LogbookMilestone moves a milestone's file off the board and into a dated logbook folder, returning its new path. The counterpart of Logbook, minus the refs: a milestone's ref deliberately stays up carrying its status, so nothing is recorded here.
It lands in a milestones/ subfolder rather than beside the tickets because a milestone's name is chosen freely and could be spelled like a ticket file. Restore has to know from where it found a file where the file belongs, and a folder says that without guessing at names.
func (*Store) LoggedPerDay ¶ added in v0.1.1
LoggedPerDay counts the logbook's tickets by the day of their folder, for the days days ending today: out[days-1] is today, out[0] the oldest. The folder name carries the date — <initials>-<yyyymmdd> — so no ticket is read, and a folder whose name does not end in a date is skipped. A folder under the logbook's old name counts too, the same way Restore finds it.
func (*Store) LoggedSince ¶ added in v0.3.4
LoggedSince reads the tickets filed into the logbook within the last days days, newest first: by the folder's day, then by the ticket's own updated_at, then by id so the order never flickers between reloads. Zero days reads nothing.
Only folders whose name ends in a date are read. The listing in 'jaira logbook' keeps an undated folder because it is the one place that names every file; the board shows what recently left it, and a folder with no day has no "recently". Filed milestones, one level deeper, are not tickets and are not read. A file that does not parse is skipped rather than reported — the board's warnings are about the board, and this is history.
func (*Store) MilestonesDir ¶ added in v0.3.0
MilestonesDir is where the board's milestone files live.
func (*Store) Mutate ¶
Mutate applies fn to a ticket under an exclusive lock, then writes it back atomically. Read-modify-write is done inside the lock so two concurrent invocations cannot each read the same original and overwrite one another — having a single write path prevents schema drift but does nothing about interleaving, which is a distinct failure mode (STORE-07).
func (*Store) Overflow ¶ added in v0.1.1
Overflow returns the tickets of a lane beyond the newest keep, oldest first. "Newest" is measured by updated-at: a ticket in a terminal lane is normally never touched again, so updated-at is its acceptance time. newest names the ticket whose arrival is being accounted for — it is pinned to the top regardless of its stamp, so the ticket that triggered a trim is never the one trimmed. Ties in updated-at are routine (the stamp has second resolution) and break toward the newer ULID: among tickets touched in the same second, the most recently created counts as newer. keep <= 0 means the lane has no cap, so nothing ever overflows. A board with unreadable tickets refuses to answer rather than guess at which ticket is oldest.
func (*Store) Paths ¶
Paths lists ticket files in deterministic order. ULID filenames sort lexicographically by creation time, so this is chronological for free.
func (*Store) RepoStateDir ¶ added in v0.1.4
RepoStateDir is the state directory every working tree of one clone shares, for state that belongs to the repository rather than to one checkout.
The background jobs' stamps live here and the per-tree state does not. When a snapshot last ran is a fact about the board, not about the checkout that happened to trigger it: keyed per tree, every new git worktree started its own clock at zero and took a full snapshot on its first command, which is how a three-day backup turned into a commit every twenty minutes.
Falls back to the per-tree directory when git cannot answer — a board outside a repository has one working tree anyway, so there the two are the same directory.
func (*Store) Restore ¶
Restore moves an archived or logged ticket back onto the board, resolving name from the bare filename in either case — never from a caller-supplied path, so a name built to escape the store (e.g. "../../etc/passwd") cannot walk it anywhere but back into TicketsDir.
func (*Store) Save ¶
Save writes a ticket that the caller already mutated, without taking a lock. Used by the merge driver, which runs single-threaded under git.
func (*Store) SessionsDir ¶
SessionsDir is where this working tree's session state lives, outside the repo.
func (*Store) SharedDir ¶
SharedDir is where lanes published to teammates live. Nothing creates this directory at init: an empty shared/ folder committed to every board that never publishes a lane would be its own kind of confusion.
func (*Store) StampCommits ¶ added in v0.1.1
StampCommits writes the MergeCommits union onto the ticket and returns what was written. derive may be nil, the same "no derivation on offer" convention core/gate uses.
func (*Store) StateDir ¶
StateDir is the per-working-tree directory this store's session and lock state lives under, exported so callers outside the package (the version stamp, in particular) can address it too.
func (*Store) TicketsDir ¶
func (*Store) TrimLane ¶ added in v0.1.1
TrimLane enforces a lane's cap after something new landed in it: the tickets beyond the newest keep leave for the given logbook folder, oldest first. newest is passed through to Overflow so the just-landed ticket is never trimmed. It returns what moved — including on error, so a caller can still report the tickets that had already left before the failure.
type Ticket ¶
type Ticket struct {
ID string
Title string
Status string
Ready bool
Creator string
Assignee string
ExecutedBy string
Goal string
Context string
DoD string
BlockedBy []string
Commits []string
ModelTier string
Question string
Mode string
ClaimedBy string
ClaimedAt time.Time
CreatedAt time.Time
UpdatedAt time.Time
// UpdatedBy is who made that most recent change. Empty on a ticket last
// written by a version that did not record it, which reads as "unknown"
// rather than as anybody.
UpdatedBy string
// Tags are the ticket's topic labels, normalized to lowercase kebab by the
// write path. Read here as written: a hand-edited file may carry anything,
// and a reader that silently rewrote it would hide the fact rather than fix
// it.
Tags []string
// Follows is the ticket whose review produced this one.
Follows string
// Parent is the ticket this one is a part of. Children are never stored;
// they are found by asking which tickets name this one here.
Parent string
// Related are tickets this one has to do with, in no particular order and
// with no obligation in either direction.
Related []string
// BlockedReason is why the ticket is parked, when the blocker is not another
// ticket.
BlockedReason string
// ReadOnly means this board can see the ticket but has no file for it: it
// is still travelling on its own git ref and nobody here has pulled it into
// work. It is shown like any other ticket and refused by every write, so a
// reader never has half a board and a writer never half-applies a change.
ReadOnly bool
// ReviewVerdict is the reviewer's conclusion, distinct from the
// implementer's own account in Outcome.
ReviewVerdict string
// ReviewSummary is what the change does, in the reviewer's own words.
ReviewSummary string
// ReviewGaps is what the reviewer found missing; "none" is a valid value.
ReviewGaps string
// ReviewCheck is the steps a person follows to check the change themselves.
ReviewCheck string
// Round is the ticket's round as written, 0 when the field is absent;
// CurrentRound reads it.
Round int
// ReviewRound is the round ReviewVerdict was written in, 0 when unknown.
ReviewRound int
Outcome Outcome
// DoDItems are the checkboxes under a "Definition of Done" heading in the
// body. A checklist is how people actually write acceptance criteria, and
// keeping it in the body means it stays readable and editable prose rather
// than being flattened into a single frontmatter string.
DoDItems []DoDItem
// PlanItems are the checkboxes under a "Plan" heading: the method being
// followed rather than the criteria for acceptance. They carry the
// in-progress marker, which is what makes "designed but not yet built"
// visible instead of collapsing into a single in-progress lane.
PlanItems []DoDItem
// Options are the checkboxes under an "Options" heading, each naming a step
// this ticket does or does not need. A lane can require one, which is how a
// ticket skips planning or review without those lanes being removed.
Options []DoDItem
// Body is the markdown following the frontmatter.
Body string
// Path is where this ticket was loaded from.
Path string
// contains filtered or unexported fields
}
Ticket is the decoded view of a ticket, used for reads, filtering and rendering. Writes never go through this struct — they go through Doc, so that unknown fields survive. Anything read into here is a projection, not the source of truth.
func LoadHeader ¶ added in v0.1.4
LoadHeader reads one ticket file's frontmatter and nothing more.
The board already reads itself this way — a ticket's body has no upper bound, and a reader that wanted only the fields would otherwise scale with total prose. Anything that has to sweep a whole directory of tickets it does not intend to display belongs here rather than on os.ReadFile: the logbook grows without end, and it is swept to answer questions as small as "who names this ticket as their parent".
The body is not populated. Everything in the frontmatter is.
func (*Ticket) Clone ¶ added in v0.5.0
Clone returns a copy that shares nothing mutable with t: every slice and the document are copied, so a caller that edits the copy cannot reach the original. The header cache hands out clones for that reason.
func (*Ticket) DoDComplete ¶
DoDComplete reports whether every checklist item is finished.
An item in progress counts as remaining. It is a statement that work is under way, which is the opposite of the criterion being met, so treating it as anything else would let a ticket close with its own checklist saying otherwise.
func (*Ticket) HasDoD ¶
HasDoD reports whether a ticket declares a definition of done at all, in either supported form.
func (*Ticket) OptionSet ¶
OptionSet reports whether a named option is ticked on this ticket.
Matching is on the text, case-insensitively and ignoring surrounding space, so "- [x] planning" and "- [x] Planning " both select the planning step. An option that is absent is not selected: a step is opt-in, so a ticket that says nothing about planning does not get planned.
func (*Ticket) PlanComplete ¶
PlanComplete reports whether every step of the method has been carried out.
A ticket with no plan is vacuously complete: the plan is optional, and its absence is not evidence of unfinished work.
type TicketSource ¶ added in v0.1.3
type TicketSource interface {
// Extra returns those tickets, skipping any id in have. Implementations
// mark what they return as ReadOnly: there is no file to write to, so a
// mutation has to be refused rather than half-applied.
Extra(have map[string]bool) ([]*Ticket, error)
}
TicketSource hands over tickets that exist for this board without a file here.
type Trimmed ¶ added in v0.1.1
type Trimmed struct {
ID string
Title string
// Path is where the file now lives, under the logbook.
Path string
}
Trimmed records one ticket a lane cap moved off the board, so the caller can say what happened — the cap is allowed to move files only because it never does so silently.
type WriteRecorder ¶ added in v0.1.3
type WriteRecorder interface {
// Record is handed the ticket's id and its complete bytes. It is called
// after the local write has succeeded, so it must never be understood as
// a veto: by the time it runs, the ticket on disk has already changed.
Record(id string, content []byte) error
// RecordFiled says the ticket has been filed away here — logged or
// archived — and hands over its final bytes.
//
// Filing is deliberately not a deletion. The ticket file is at that moment
// only in the filer's own branch, and until that branch is merged nobody
// else can see the ticket at all: taking it off the shared channel too
// would open a window, as long as a review takes, in which somebody
// notices the same problem and writes it down a second time. So the final
// state goes onto the channel and stays there until the ticket has
// arrived somewhere everybody can see.
RecordFiled(id string, content []byte) error
// RecordDelete says the ticket is gone for good. Only 'jaira delete' does
// this: deleting is an intention, not a stage of finishing.
RecordDelete(id string) error
}
WriteRecorder is told what a ticket now says, after the file has been written.