-
Notifications
You must be signed in to change notification settings - Fork 0
Worklog Spec
Version: 1.6 Status: Implemented through §18 step 8; wiki publish (step 9) done for github-wiki; step 10 outstanding; step 11 (typed adapter contract) in progress on this branch
Changes since 1.5: §8.1 gains a hosted-platform caveat — GitHub's server-side merge does not run .gitattributes merge drivers (even built-in union), so concurrent PRs conflict in the web UI; documented recovery (found merging PR #24, issue #25). §9 gains §9.5: the typed adapter contract layer (docs/plans/2026-07-18-typed-adapter-contract.md) now shipping — the dispatcher owns the sync invariants in code, adapters are generated dumb translators validated by worklog adapter check, and a fake adapter makes the invariants CI-testable with no network. Skills orchestrate; the dispatcher enforces. §18 gains step 11.
Changes since 1.4: remaining adapter references purged (§4.2 config examples, §5.3, §13.3, §15) — the 1.4 §9 rewrite left strays behind. §10 now documents the shipped pull CLI: ingest via worklog ingest (deterministic ev per §10.2), conflicts via worklog conflict, resolution via worklog resolve <item> --field F --take local|remote. §18 step 8 done.
Changes since 1.3: §9 rewritten from an executable adapter contract to skill-based edges — per-system integration is model work using the team's existing CLIs/MCPs (gh, glab, az, jira, or an MCP server); no adapter binaries ship. §12's skills table updated to the shipped set (plan-capture, work-track, plan-next, wiki-publish, ticket-sync, plus the UserPromptSubmit/Stop/SessionStart hooks added in 0.2.0). §18 progress updated: step 6 superseded by skills, steps 7 and 9 (github-wiki) done.
Changes since 1.2: §8.2 corrected by measurement — git's ort union driver repairs a missing final newline at merge time; the real fusion risk is the local append path, and append() now self-heals it. Added hooks/pre-merge-commit (merge auto-commits bypass pre-commit), mktemp in the freshness check, and a PR-simulation integration suite (tests/test_integration.py).
Changes since 1.1: reference implementation + 32 passing tests added as Appendix A; repo skeleton as Appendix B; two spec bugs found by implementing it — item is not required on compact events (§5.2), and §7's _moved tombstone was redundant and is removed.
Changes since 0.1: bin/ and sync scratch paths added to the layout (§3); ingested-event ts corrected (§10.2); open question #2 closed via config-only settings + agent-file symlink (§4.1); status reports rewritten into three named kinds with frozen semantics (§13.3).
Audience: Implementers of the skill pack; junior devs and PMs reading the generated roadmap.
A local-first, git-native work tracking layer for agentic coding (Claude Code, Grok build, or any harness that can read CLAUDE.md and run scripts).
Three properties drive every decision:
- Visible WIP. Everything in flight is in the repo, in the ticketing system, and on the wiki. No hidden work.
- Plans produce tickets. Planning that doesn't emit tracked items is planning that evaporates.
- Generic core, pluggable edges. The core never knows the word "Jira." Ticketing and wiki specifics live in separate, swappable edge skills (§9).
- Append-only event log of work items in the repo, mergeable across branches and devs.
- Human-readable roadmap, plans, and status reports generated from that log.
- Best-effort bidirectional sync with one ticketing system (Jira / Azure DevOps / GitHub Issues).
- Publish to one wiki (Confluence / GitHub Wiki / ADO Wiki).
- Capture unplanned work discovered mid-flight, attributed to what it interrupted.
- Not a replacement for the ticketing system. It's a mirror with a local source of truth.
- Not strongly consistent. Drift happens; the spec's job is to make drift visible, not impossible.
- Not a scheduler, estimator, or burndown tool. Those read the log; they aren't in it.
-
No hand-editing of generated artifacts. Humans change work through the agent, not by editing
roadmap.md.
CLAUDE.md # policy prose + pointer to config
AGENTS.md -> CLAUDE.md # symlink; see §4.1
.work/
config.yml # machine-readable settings (committed)
todo.jsonl # append-only event log, open items
done.jsonl # append-only, closed items (written by compactor only)
published.json # doc path -> wiki page id/url (committed)
sync-state.json # per-clone sync bookkeeping (GITIGNORED)
changeset.json # sync phase-1 output (GITIGNORED)
results/ # sync phase-2 output (GITIGNORED)
ticket.json
wiki.json
schema/
item.schema.json
event.schema.json
bin/
worklog # CLI entry point; everything below is a subcommand
fold.py # state derivation (§6)
compact.py # §7, CI-only
render_roadmap.py # §13.1
ulid.py
.claude/skills/ # or .grok/skills/ — see §4.1
docs/
roadmap.md # GENERATED, CI-guarded
plans/
2026-07-16-auth-refactor.md # written once, never regenerated
status/
2026-07-16-daily.md # frozen once published
2026-07-13-weekly.md
2026-07-16-timecard.md
.gitignore must contain .work/sync-state.json, .work/changeset.json, and .work/results/.
Why sync scratch lives in .work/ and not /tmp: a failed sync leaves changeset.json and whatever results/ got written on disk, next to the log they describe. That's the difference between a debuggable failure and a mystery.
Prose the model reads. Contains no values the scripts need, which is what closes the Claude Code / Grok Build parity question: every real setting is in .work/config.yml, so the agent file is a thin policy document and AGENTS.md is a symlink to CLAUDE.md. One file, one policy, no fork. Skills are discovered per-harness (.claude/skills/, .grok/skills/); the bin/worklog CLI is identical under both.
Do not write harness-specific notes into the policy. "Grok reads this file too" is a fact about Grok that only Claude will ever read.
Recommended block:
## Work tracking policy
- Work items live in `.work/todo.jsonl`. Settings live in `.work/config.yml`. Read the
config file; do not infer settings from this document.
- **Exiting plan mode is not optional bookkeeping.** Every plan MUST end by running
`worklog plan-capture`, which writes `docs/plans/<date>-<name>.md` and appends the
plan's steps as work items.
- **Plans are the permanent design record.** `docs/plans/` is where the *why* lives —
it is the specification half of spec-driven development, and it is checked into the
repo and published to the wiki for exactly that reason. A plan is written once and
never regenerated or rewritten. If the design changed, write a NEW plan that
supersedes the old one; do not edit history. When you need to know why a decision
was made, read the plan, not the ticket.
- If you discover work mid-flight that was not in the plan (a bug, a missing migration,
a broken test), run `worklog add --unplanned --discovered-during <current-item>`
BEFORE doing the work. Do not silently absorb it.
- When a plan is complete, run `worklog sync`. This closes items, moves them to
`done.jsonl`, updates the ticketing system, regenerates the roadmap, and publishes.
- When asked for a status report, run `worklog status --kind <daily|weekly|timecard>`.
It writes `docs/status/<date>-<kind>.md`, commits it, and publishes it to the wiki.
Never paste a status report into chat without writing the file — an unrecorded status
report is a rumour.
- Status reports are FROZEN once published. If last Tuesday's daily is wrong, write a
correction in today's report. Do not regenerate an old one.
- Never hand-edit `docs/roadmap.md`. It is generated. To change the roadmap, change
the work items.
- Never edit `.work/*.jsonl` with a text editor or a shell redirect. Use `worklog`.version: 1
project:
key: PROJ # prefix for human-facing item refs
name: "Acme Platform"
ticketing:
system: jira # jira | ado | github | none
# sync is skill-based (ticket-sync); no adapter binary
project: PROJ
# everything below here is system-specific; the skill reads it, the core does not
options:
site: acme.atlassian.net
epic_link_field: customfield_10014
wiki:
system: confluence # confluence | github-wiki | ado-wiki | none
# publishing is skill-based (wiki-publish); no adapter binary
root_url: https://acme.atlassian.net/wiki/spaces/ENG
options:
space: ENG
root_page_id: "12345"
paths:
plans: docs/plans
status: docs/status
roadmap: docs/roadmap.md
status:
default_kind: daily
week_starts: monday
timecard:
include_hours: false # see §17 open question 4
actor: rick # whose work the timecard covers
publish: true # push status reports to the wiki on generation
sync:
active_window_days: 14 # scope of `sync --scope active`
conflict_policy: report # report | local-wins | remote-wins
push_on_capture: trueNot configurable, deliberately: the body-size cap (§8.3) is derived from PIPE_BUF, not chosen. It lives in the code as a constant with a comment. Exposing it as max_body_kb invites someone setting it to 64 and silently losing atomic appends — a knob whose only valid value is the default is a trap, not a feature.
Resolution rule: there is no executable to resolve. The skills read ticketing.system and wiki.system; the core never branches on either beyond checking for none (§9.1). If the tooling a skill needs is missing, everything degrades to local-only with a warning — it never fails a command.
Each line of todo.jsonl is one immutable event. State is a fold over events. This is what makes concurrent multi-dev edits merge instead of clobber.
{"ev":"01J8X2K4A0","ts":"2026-07-16T14:02:11Z","actor":"rick","item":"01J8X0M2QQ","op":"create","set":{"type":"task","title":"Extract auth middleware","parent":"01J8WZZ100","status":"todo","priority":"P1"}}
{"ev":"01J8X2M900","ts":"2026-07-16T14:09:03Z","actor":"rick","item":"01J8X0M2QQ","op":"update","set":{"status":"in_progress"}}
{"ev":"01J8X4RR10","ts":"2026-07-16T15:40:00Z","actor":"jira","item":"01J8X0M2QQ","op":"update","set":{"priority":"P0"},"src":{"system":"jira","key":"PROJ-412","rev":"2026-07-16T15:39:58Z"}}| Field | Type | Req | Notes |
|---|---|---|---|
ev |
ULID | yes | Event ID. Primary sort key and dedupe key. |
ts |
RFC3339 UTC | yes | Human/debug timestamp. Not used for ordering. |
actor |
string | yes |
rick, claude, jira, sync, compactor. Who caused this. |
item |
ULID | yes* | The work item this event concerns. *Except op: compact, which is log-level, not item-level — see §7. Validators must special-case it. |
op |
enum | yes | See 5.3. |
set |
object | — | Scalar field assignments. |
add / del
|
object | — | Set-valued field mutations (see 5.5). |
src |
object | — | Provenance for ingested remote changes: {system, key, rev}. |
note |
string | — | Free text; shown in history, never parsed. |
op |
Meaning |
|---|---|
create |
First event for an item. set must include type and title. |
update |
Field changes. |
close |
Sets status to done or cancelled; requires set.status and optional set.resolution. |
reopen |
Moves a closed item back. The compactor pulls it from done.jsonl on next run. |
link |
Records external identity: set.external. Appended by worklog link after a successful push (§9.2). |
conflict |
Records an unresolved both-sides-changed field. Never changes state; surfaced in reports. |
snapshot |
Compactor output only. Full item state; supersedes all events with ev ≤ watermark. |
compact |
Compactor watermark marker. {"op":"compact","through":"<ulid>"}
|
| Field | Type | Notes |
|---|---|---|
id |
ULID | Implicit (the item field). The primary key, always.
|
type |
epic | story | task | subtask | bug
|
|
title |
string | ≤ 120 chars. |
body |
markdown | Optional longer description. |
parent |
ULID | null | Single parent. Hierarchy is the parent chain. |
status |
todo | in_progress | blocked | done | cancelled
|
|
priority |
P0 | P1 | P2 | P3
|
|
estimate |
XS | S | M | L | XL
|
Optional. |
depends_on |
ULID[] | Set-valued. Blocks scheduling, distinct from parent. |
labels |
string[] | Set-valued. |
assignee |
string | Optional. |
plan |
path | The plan doc that produced this item. |
unplanned |
bool | True if discovered mid-flight. |
discovered_during |
ULID | What it interrupted. Required when unplanned is true. |
resolution |
string | Set on close. |
external |
object | {system, key, url, synced_at, hash} |
On epic: there is no separate epic field. An epic is an item with type: epic. Ancestry is walked. The roadmap renderer denormalizes to a root epic for grouping; the log does not.
On IDs: the ULID is the key. Never key on external.key — items exist locally before they exist remotely, and remote keys don't survive a system migration.
labels and depends_on use add/del, not set, so two devs adding different labels don't clobber each other:
{"ev":"...","item":"...","op":"update","add":{"labels":["backend"]},"del":{"labels":["triage"]}}Fold applies del then add per event. set on a set-valued field is legal but replaces wholesale — reserved for the compactor.
State is derived, never stored (except as compactor snapshots).
fold(lines) -> {item_id: item_state}
1. Parse each line. On parse error: emit to stderr, skip the line, do NOT abort.
A corrupt line must never prevent reading the rest of the log.
2. Dedupe by `ev`. First occurrence wins (they're identical by construction).
3. Sort ascending by `ev`. ULIDs are lexicographically time-ordered, so this is a
plain string sort. Ties broken by `actor` then full-line hash — deterministic
across machines.
4. Discard every event with `ev` <= the highest `compact.through` watermark,
EXCEPT `snapshot` events.
5. Apply in order:
create -> initialize item, apply `set`
snapshot -> replace item state entirely
update -> per-field last-writer-wins: apply `set`, `del`, `add`
close -> apply `set`, mark closed
reopen -> clear closed
link -> apply `set.external`
conflict -> append to item._conflicts, no state change
6. Events for an unknown item (no `create`, no `snapshot`) create a partial item
with `_orphan: true`. Report; do not crash. This happens legitimately mid-rebase.
Ordering is by ev (ULID), not ts. ULIDs are wall-clock derived, so a dev with a fast clock wins LWW ties. Accepted for v1 (see §16). actor and ts are on every event precisely so "why did my priority flip back?" is answerable by reading the log.
Both files fold together. fold(todo.jsonl + done.jsonl) is the full history. Most commands only need todo.jsonl.
Compaction is the only operation that rewrites a file, and therefore the only one that can truly conflict.
- Main branch only. CI only. Never on a feature branch.
-
Its commit contains nothing else. Message:
chore(worklog): compact through <ulid>. - Devs rebase after. A rebase across a compaction is safe because union merge (§8) re-adds any branch events, and they sort after the watermark.
1. Fold todo.jsonl.
2. watermark = max(ev) across all input lines.
3. Partition items: open (status in todo/in_progress/blocked) vs closed (done/cancelled).
4. Write todo.jsonl:
- one {"op":"snapshot", "item": id, "set": <full state>} per OPEN item
- one {"op":"compact","through": watermark} line
5. APPEND to done.jsonl:
- one snapshot per newly-CLOSED item
- a compact watermark
6. Reopened items: **no tombstone needed.** A `reopen` event necessarily has a higher
`ev` than the `done` snapshot that precedes it, so folding `todo.jsonl + done.jsonl`
together by `ev` yields the reopened state for free. (0.1 specified a `_moved`
tombstone here; implementing it revealed it was redundant. Removed.) What the
compactor *does* owe: **prune from `done.jsonl` any item that is currently open**,
or stale snapshots accumulate there forever. They're harmless — a later snapshot
always outsorts them — but the file grows without bound.
7. Verify: fold(new) == fold(old) for all items. Abort and leave the file untouched
if not. Compaction that loses state is the worst failure mode in this system.
8. Verify trailing newline. Verify every line parses.
Closing an item does not move a file at runtime. close is just an event. The compactor is what physically relocates state into done.jsonl. This is deliberate: it means no runtime command ever writes two files, which removes the write-conflict from the parallel-subagent phase (§11).
Scheduled (nightly) or when todo.jsonl exceeds a line threshold (default 5000). Never automatic on a dev's machine.
# .gitattributes
.work/todo.jsonl merge=union
.work/done.jsonl merge=union
Union merge takes both sides of a conflict, always. No merge conflicts on the log, ever. Three consequences the design already absorbs:
| Consequence | Why it's fine |
|---|---|
| Line order after merge is arbitrary | The fold sorts by ev. The file is a bag, not a sequence. |
| Duplicate lines appear | The fold dedupes by ev. |
| A missing trailing newline fuses two lines into one corrupt line | Enforced by writer invariant + pre-commit hook. See below. |
Hosted-platform caveat (added 1.6): GitHub — and most hosting platforms — do not run merge drivers server-side, not even the built-in union. The no-conflict guarantee above holds for local merges only; two PRs touching .work/todo.jsonl will conflict in the web UI even though a local merge sails through (issue #25, found merging PR #24). Mitigations, in order: keep PR branches rebased/merged-up locally before merging in the UI; and when the UI does report conflicts, recover locally — merge the base branch on the PR branch (union applies), worklog roadmap-render, union published.json by key, push, then merge the PR. Costs one paragraph here; saves the next person an afternoon.
Every write to a .jsonl file must terminate with \n.
(Corrected in 1.3, by measurement.) The 1.2 text claimed union merge fuses {...}{...} when one side's last line lacks a newline. Integration testing shows git's ort union driver repairs a missing final newline at merge time. The real fusion path is local: an O_APPEND write onto a file whose last line lost its newline (a hand edit) fuses two events into one unparseable line — losing both.
Enforcement, four layers:
-
append()self-heals: if the file's last byte is not\n(a hand edit the hook hasn't caught yet), the next append prepends\n— still onewrite()call. - Writers open with
O_APPENDand writejson + "\n"in a singlewrite()call. - Pre-commit hook:
[ -z "$(tail -c1 .work/todo.jsonl)" ] || exit 1— plushooks/pre-merge-commitrunning the same script, because git does not runpre-commitfor merge auto-commits (a merge could otherwise land a stale roadmap or a smuggled corrupt line that only CI would catch). - CI: same check, plus every line parses as JSON against
event.schema.json.
Single write() of a line under O_APPEND is atomic for lines under PIPE_BUF (4096 bytes on Linux). Lines exceeding that (a long body) must take an advisory lock (flock on .work/.lock). Simpler alternative: cap body in the log at 2 KB and store longer prose in the plan doc, referenced by path. Recommended: cap it.
(Rewritten in 1.4. The 1.2 design specified per-system CLI adapter executables — ticket-<system> push, a capabilities handshake, an exit-code table. None of that shipped, and none of it will.)
The implementation went a different way, for a reason worth recording: the model already knows these systems. GitHub Issues, Jira, Azure DevOps, GitLab, Confluence — an LLM has absorbed their docs, their CLIs, and years of answers about both. Shipping per-system adapter binaries means writing, testing, and maintaining executables whose whole job is to re-encode knowledge the model already has, then chasing remote API drift forever. That's dead weight. Instead, each edge is a skill: prose that tells the model to read the config, use whatever tooling the team already has — gh, glab, az boards, jira, or an MCP server — and research gaps (docs/web) rather than guess. Per-system integration is model work, not shipped code. If tooling is missing, the skill tells the human what to install; the core is untouched and local-only keeps working.
What survives from 1.2 is the shape of the boundary, not the mechanism:
.work/config.yml's ticketing.system and wiki.system name the edge (github, jira, github-wiki, confluence, …). The skills read those names; bin/worklog never branches on them beyond checking for none (invariant §15.6). Swapping trackers is a config edit, not a code change.
Local items become tickets. The pull side ships too (§18 step 8): remote changes are ingested per §10 via worklog ingest.
-
Scope — every open item (
todo/in_progress/blocked), plus any closed item still carrying dirty external identity — canonical hash ≠last_pushed_hash— which stays in scope exactly until its closure is pushed. Once hashes match, the item is inert and never rescanned (§10.5). -
Skip unchanged — §10.3's canonical hash, compared against
items.<ulid>.last_pushed_hashin the gitignored, per-clone.work/sync-state.json. Equal → skip. -
Idempotency — the rule that moved here intact from the adapter contract, because it was always the single most important one: push must be idempotent, keyed by item ULID (invariant §15.5). Every ticket body embeds a marker line,
<!-- worklog:<ULID> -->; before creating, the skill searches the tracker for that marker and updates the hit instead of creating. A retried push must find, never duplicate — without this, a retried subagent opens duplicate tickets. -
Recording — after a successful push,
worklog link <ulid> --system <s> --key <k> --url <u> --hash <h>appends the §5.3linkevent. This is the only way external identity enters the log. Thenlast_pushed_hashis updated in sync-state. -
Close — a locally closed item (
done/cancelled) closes the remote ticket with a short comment naming the resolution, then updateslast_pushed_hash. No secondlink— identity is already recorded.
-
Ledger —
.work/published.json, committed, because page identity is shared across the team; without it every republish creates duplicate pages. Entries map a stable logical key to{source, title, url, rev, source_hash}—source(the repo path) makes the publish set self-describing. -
Default publish set — the live roadmap, every plan in
docs/plans/, every roadmap snapshot indocs/roadmap/, plus anything registered viaworklog wiki-add <file> --key K --title T. -
Frozen rules (§13's semantics applied at the edge) — plans, snapshots, and status reports publish once and are never re-published. The live roadmap is the exception: republish when
source_hashchanges; matching hash → skip. -
One-time init is surfaced to the human, never silently worked around — e.g. a GitHub wiki's
.wiki.gitdoes not exist until someone clicks "Create the first page"; on a not-found failure, ask the human to do that once, then retry.
-
worklog link— the only writer of external identity into the log. -
worklog wiki-add— registers a file in the ledger ({source, title, url: null, rev: null, source_hash: null}; the next publish fills the nulls). - The file formats — the
published.jsonledger shape above andsync-state.json(§10.3) — are fixed by this spec, whatever tooling the skill happens to drive.
The old §9.1 exit-code table is gone: there is no executable whose exit codes could be specified. What did not move: the sync semantics. §10's canonical hash, echo suppression, field directions, and conflict rules still govern — the skill is the how; §10 remains the what.
The 1.4 move to skills won portability but demoted three invariants from code-enforced to prose-enforced: idempotency (search-before-create), pull parsing (the fields that seed the deterministic ev), and capability degradation (what happens when a tracker has no epic type). The typed contract layer restores them as code without recreating the per-system maintenance burden this spec escaped: adapters stay generated and dumb — pure translators between canonical JSON and the platform, validated by worklog adapter check — while the dispatcher owns every invariant: hash, marker, scope, echo suppression, conflicts, and the capabilities gate that rejects a subtly wrong adapter at a typed boundary instead of three steps later. A shipped fake adapter, backed by a local JSON file, makes all three invariants CI-testable with no network. Full spec: docs/plans/2026-07-18-typed-adapter-contract.md.
This supersedes §9.2's "the skill performs these steps" wording: the skill now runs worklog adapter check, then worklog sync, and reads the drift report. Skills orchestrate; the dispatcher enforces.
Do not build a second merge path. Remote changes are ingested as events into the same log with actor: "<system>" and a src block. The existing per-field LWW fold handles them. One code path, one conflict story.
ev = ULID(time = remote.rev_timestamp, entropy = sha256(system | key | remote.rev)[:10 bytes])
Two devs polling Jira independently produce the identical line. Union merge takes both, the fold dedupes by ev, one survives. Ingest is therefore idempotent across clones — which means there's no need for a single blessed syncer, and any dev running sync is safe.
This is what ships as worklog ingest: it computes the deterministic ev above and appends the ingested event — the ev is derived, never random.
ts on an ingested event is remote.rev, not now(). Otherwise two devs polling the same change produce lines that share an ev but differ in ts — dedupe still works, but §5.2's claim that duplicates are "identical by construction" becomes false, and a byte-comparison in a test or a CI check would flag a phantom difference. Ingested events carry the remote's clock, not the poller's.
The hash goes in the ULID's entropy field, not a sidecar. A ULID is 48 bits of timestamp + 80 bits (10 bytes) of entropy; sha256(...)[:10] fills it exactly. A tempting-looking alternative is to keep ev random and put the hash in a separate field like ev_remote. Do not do this. The fold dedupes by ev, so a random ev means both devs' ingests survive, and this fails in a way that looks harmless:
dev A ingests Jira P0 -> ev = X
Rick sets priority P2 -> ev = Y (X < Y)
dev B ingests same P0 -> ev = Z (Y < Z)
fold order: X(P0), Y(P2), Z(P0) -> P0
Rick's local edit is silently reverted by a duplicate of a change that predates it — violating invariant §15.11. Applying the same set twice is idempotent in isolation, which is exactly why this survives casual testing. Deterministic ev means there is only ever one X, and the fold yields P2. A sidecar field would require a second dedupe path keyed on it, in a design whose whole premise is that the remote is just another writer through one code path.
Without this you get an infinite loop: push → remote updated_at bumps → poll → see "remote change" → ingest your own push → push again.
.work/sync-state.json (gitignored, per-clone):
{
"cursors": {"jira": "2026-07-16T15:40:00Z"},
"items": {
"01J8X0M2QQ": {
"last_pushed_hash": "a3f1...",
"last_remote_rev": "2026-07-16T15:39:58Z",
"last_pull": "2026-07-16T15:45:00Z"
}
}
}On pull, ingest an item only if hash(remote_synced_fields) != last_pushed_hash. The hash covers only the fields we actually sync (§10.4) — a Jira watcher change must not read as drift.
hash = sha256(canonical_json({title, body, type, status, priority, parent, labels, assignee}))[:16]
Canonical JSON = sorted keys, no whitespace, arrays sorted for set-valued fields.
Note the per-clone gap: a fresh clone has no sync-state.json, so its first pull sees everything as remote-changed. Mitigation: on first pull, if hash(remote) == hash(local), write last_pushed_hash and ingest nothing. Only genuine differences produce events.
| Field | Local → Remote | Remote → Local | Notes |
|---|---|---|---|
title, body
|
yes | yes | LWW |
type |
yes | on create only | Type changes remotely are ignored; noted in drift |
status |
yes | yes | Mapped by the skill (§9.2) |
priority |
yes | yes | LWW — the PM re-prioritizing in Jira is a feature |
parent |
yes | yes | If supports_parent
|
labels |
yes | yes | Union, not LWW |
assignee |
yes | yes | LWW |
depends_on |
if supported | if supported | Often lossy; report |
estimate |
yes | no | Local-authoritative |
plan, unplanned, discovered_during
|
as a comment/label | no | Local-only concepts |
id (ULID) |
as idempotency key | never |
worklog sync # == sync --scope active --apply
worklog sync --scope active # status in (todo,in_progress,blocked)
# OR updated within active_window_days
# OR external.dirty
worklog sync --scope all # everything open; slow; manual only
worklog sync --keys PROJ-412,... # targeted
worklog sync --report # print drift, change NOTHING
worklog sync --apply # apply LWW, emit conflicts
worklog sync --dry-run # print the events that WOULD be appended
Closed and archived items never reconcile. That's what keeps the scope bounded. If someone reopens a ticket in Jira, pull catches it only while it's inside active_window_days; past that, it's a manual --keys sync. Documented limitation, not a bug.
One push-side exception (external.dirty): a closed item whose external identity is dirty — canonical hash ≠ last_pushed_hash — stays in scope exactly until its closure is pushed to the remote (§9.2). Once hashes match, it is inert and never rescanned.
A conflict is: the same field changed on both sides since last_pushed_hash, to different values.
Under conflict_policy: report (the default), worklog conflict appends:
{"ev":"01J8X9...","ts":"...","actor":"sync","item":"01J8X0M2QQ","op":"conflict",
"set":{"field":"priority","local":"P1","remote":"P0","remote_rev":"2026-07-16T15:39:58Z"}}State does not change. The conflict appears in:
-
worklog sync --reportoutput - the next generated status report, under Needs attention
worklog show <item>
Best-effort with visible drift beats eventual consistency with silent loss. Resolve with worklog resolve <item> --field F --take local|remote, which appends a normal update event and clears the conflict.
Fold rule: a conflict clears whenever a later event writes the conflicted field — resolve is just the explicit way to produce that write; a fresh update or a subsequent ingest of that field clears it the same way.
local-wins / remote-wins policies skip the conflict event and just apply. Available, not recommended.
Your instinct to fan out subagents is right, but the naive version has three writers racing on todo.jsonl. Phase it:
One agent. Folds the log, computes what changed, appends events, emits changeset.json to a temp path:
{"changeset":"01J8XA...","items":[
{"id":"01J8X0M2QQ","action":"push","state":{...}},
{"id":"01J8X0M3RR","action":"close","state":{...}},
{"id":"01J8X0M4SS","action":"create","state":{...}}
]}Three subagents, all reading changeset.json, none writing the log:
| Subagent | Does | Writes to |
|---|---|---|
ticket-sync |
push/close/comment via the tracker's CLI/MCP (the §9.2 skill) | results/ticket.json |
wiki-publish |
publish roadmap, plans, status via the wiki's tooling (the §9.3 skill) | results/wiki.json |
roadmap-render |
regenerate docs/roadmap.md
|
the doc |
Every phase-2 action is idempotent and keyed by item ULID. A retried subagent must be a no-op, not a duplicate. Phase 2 may fail partially — that's what best-effort means.
Folds results/*.json back into the log as events:
{"ev":"...","actor":"sync","item":"01J8X0M2QQ","op":"link","set":{"external":{"system":"jira","key":"PROJ-412","url":"...","synced_at":"...","hash":"a3f1..."}}}Updates .work/published.json and .work/sync-state.json. Prints a summary: pushed, closed, conflicted, deferred.
Invariant: exactly one process appends to the log at a time within a single worklog invocation. Concurrency across devs is handled by union merge, not by locking.
Core skills are thin wrappers over deterministic scripts — the model decides when, the script decides what. Edge skills (§9) are the exception: the skill is the integration, model work over the team's own tooling.
The shipped set:
| Skill | Trigger | Behavior |
|---|---|---|
plan-capture |
Plan mode exits; user says "capture this plan" | Writes docs/plans/<date>-<slug>.md, parses the ## Tasks checkboxes → items with plan set, appends create events, regenerates the roadmap. |
work-track |
Any request that produces work; "mark that in progress"; mid-flight discovery | add / update / close via bin/worklog. Discoveries get --unplanned --discovered-during <id>. (Replaces 1.3's work-add / work-update / work-close trio — one skill, three subcommands.) |
plan-next |
"what should we do next?" | Folds log. Filters open, unblocked (depends_on all closed), sorts by priority then epic order. Presents top N with rationale. Read-only.
|
ticket-sync |
"sync tickets"; a plan closes out | Push per §9.2 and pull per §10 — model work over the tracker's CLI/MCP; ingests via worklog ingest/worklog conflict. |
wiki-publish |
"publish to the wiki"; after a roadmap snapshot | Publish per §9.3 — ledger-driven, frozen rules enforced. |
Still specified, not yet shipped: status-report (§13.3) and worklog-compact (§7, CI only). roadmap-render shipped as a worklog subcommand rather than a skill — the callers above invoke it directly.
"Anytime we do planning, output tasks" will get skipped if it depends on the model remembering. Back the policy with mechanism:
| Hook | Action |
|---|---|
PostToolUse on ExitPlanMode
|
Invoke plan-capture non-optionally. |
UserPromptSubmit (0.2.0) |
One-line policy reminder: if this request produces work, record it first. |
Stop (0.2.0) |
If todo.jsonl has open in_progress items with no matching git activity, warn. |
SessionStart (0.2.0) |
doctor-lite: read-only health check; reports failures only, never blocks. |
PreCommit (git) |
Trailing-newline check; schema validation; roadmap.md freshness. |
PreMergeCommit (git) |
Same script as PreCommit — merge auto-commits bypass pre-commit. |
A CLAUDE.md instruction holds maybe 80% of the time. A hook holds 100%. Use prose for judgment, hooks for invariants.
docs/roadmap.md opens with:
<!-- GENERATED by worklog roadmap-render. DO NOT EDIT. -->
<!-- source-hash: a3f1c9e2 -->
<!-- generated-at: 2026-07-16T16:00:00Z -->
> This file is generated from `.work/todo.jsonl`. Edits will be overwritten.
> To change the roadmap, ask Claude Code — e.g. "move the SSO epic ahead of billing"
> — or run `worklog update`.Because the roadmap is read-only, there is no parser. No anchors, no round-trip, no ambiguity about which bullet became which ticket. This is the single biggest simplification in the spec.
CI check: regenerate, diff, fail the build if it differs. That's what teaches the team where to make changes, and it costs one job.
Structure — written for a junior dev or a PM, so: prose first, tables second, no ULIDs in the reading path.
# Roadmap — Acme Platform
_Generated 2026-07-16. 3 epics in flight, 14 open items, 2 blocked._
## Now
### Auth refactor · P0 · 6 of 9 done
Replacing hand-rolled session handling with middleware. Blocks the SSO work.
| # | Item | Type | Priority | Status | Blocked by |
|---|---|---|---|---|---|
| [PROJ-412](https://...) | Extract auth middleware | task | P0 | in progress | — |
| [PROJ-418](https://...) | Session store migration | task | P1 | todo | PROJ-412 |
## Next
...
## Later
...
## Needs attention
- **PROJ-412** — priority conflicts: local `P1`, Jira `P0`. Resolve with
`worklog resolve 01J8X0M2QQ --field priority --take remote`.Ordering: Now = P0 + anything in_progress. Next = P1 + unblocked P2. Later = the rest. Within a section, epic order then priority then creation order.
docs/plans/<YYYY-MM-DD>-<slug>.md. Committed and published. Front matter links the plan to its items:
---
date: 2026-07-16
slug: auth-refactor
items: [01J8X0M2QQ, 01J8X0M3RR]
epic: 01J8WZZ100
wiki: https://acme.atlassian.net/wiki/spaces/ENG/pages/88219
---Plans are written by a human/agent conversation and never regenerated. They're the record of why, and the fact that they can go stale is fine — that's what a dated document is.
Plans are the spec half of spec-driven development. The ticket says what and when; the plan says why, and what we considered instead. That's the artifact that survives the ticket being closed, the sprint ending, and everyone forgetting. Three consequences:
-
A plan supersedes, it never edits. A design that changed gets a new dated plan with
supersedes: 2026-07-16-auth-refactorin its front matter. The renderer chains them so the wiki shows the current one with a link back. Editing a plan in place destroys the only record you have of why the first approach was abandoned — which is precisely the thing someone will ask about in six months. -
Plans publish to the wiki like everything else (
published.jsonkeyplan/<slug>), so the PM reading the roadmap can click from an epic to the plan that produced it. - Plans are the one generated-adjacent artifact with no CI hash check. They're prose, written once. Guarding them would only prevent typo fixes.
Front matter fields: date, slug, items[], epic, wiki, optional supersedes, optional superseded_by (written by plan-capture when a successor appears).
docs/status/<YYYY-MM-DD>-<kind>.md. Generated on demand, committed, and published to the wiki — same wiki-publish skill, same published.json, key status/<date>-<kind>.
worklog status --kind daily # default window: since last daily
worklog status --kind weekly [--week 2026-W29]
worklog status --kind timecard [--since 2026-07-13] [--until 2026-07-17]
worklog status --kind daily --dry-run # print, write nothing
This is the fork worth being explicit about. The roadmap is regenerated and CI hash-checked because it describes the present, which has exactly one correct value. A status report describes a moment, and it was sent to people who acted on it.
So: status reports are generated on demand, then frozen. Regenerating last Tuesday's daily today would produce different text — conflicts have since resolved, priorities moved, an item got reopened — and you'd have silently rewritten something a human read and made a decision from. They get no CI hash check, and worklog status refuses to overwrite an existing file without --force.
They are, however, reproducible: front matter records the exact watermark, so anyone can reconstruct the inputs.
---
kind: daily
date: 2026-07-16
window: {from: 2026-07-15T09:00:00Z, to: 2026-07-16T09:00:00Z}
through: 01J8XA4K20 # max ev included; the report is a fold over ev <= this
generated_at: 2026-07-16T09:00:12Z
wiki: https://acme.atlassian.net/wiki/spaces/ENG/pages/88231
---If a report was wrong, the correction goes in the next report, not into the old one. Same rule as the plans, for the same reason.
daily |
weekly |
timecard |
|
|---|---|---|---|
| Audience | the standup | PM, stakeholders, whoever missed the week | you, and whoever you bill |
| Window | since last daily |
week_starts → +7d |
arbitrary --since/--until
|
| Shape | terse bullets | prose + tables, grouped by epic | one paragraph per day, in date order |
| Detail | item-level | epic-level rollup with item tables | narrative only — no ticket tables |
| Answers | what changed, what's blocked | what shipped, what slipped, what's next | what did I spend the day on |
daily — for the meeting. Shipped since yesterday, in flight, blocked (and by what), conflicts needing a human. Bullets, no prose, fits on a screen.
weekly — everything closed in the window grouped by epic, plus slippage (items still open that were in_progress at the start of the window), plus the unplanned rollup, plus what's queued next per plan-next. Tables are fine here.
timecard — one paragraph per day, and deliberately nothing else. No tables, no ticket IDs in the reading path, no status columns. It answers "what did Monday consist of" in a couple of sentences a human can skim. Days with no activity are omitted, not padded.
## Monday, 14 July
Spent most of the day on the auth middleware extraction (PROJ-412), which turned out
to depend on the session store schema more than the plan assumed — filed PROJ-431 as
unplanned work and got the migration written. Afternoon went to review on the billing
epic.
## Tuesday, 15 July
Finished PROJ-431 and landed PROJ-412. Started the SSO spike; nothing to show yet.Worth being precise, because this is the one place the pipeline isn't deterministic. render_roadmap.py is pure code: fold in, markdown out. A timecard paragraph is a synthesis job — that's the model's, not the script's.
So the split is:
-
bin/worklog status --kind timecard --emit-factsfolds the log and emits per-day structured facts (items closed, items opened, unplanned items and what they interrupted, commits touched if available) to stdout. Deterministic. - The
status-reportskill reads those facts and writes the prose. -
worklog status --writetakes the prose on stdin, adds front matter withthrough, writes the file, commits, publishes.
This is also why status reports can't be CI-hash-checked: step 2 isn't reproducible byte-for-byte, and that's fine. Freeze the output instead of trying to verify it.
daily and weekly both carry:
- Shipped — closed in window, grouped by epic
-
In flight —
in_progress, with age (age is the useful column; a 9-day-old "in progress" is the whole story) - Blocked — plus what blocks them
-
Unplanned work — items with
unplanned: true, and what they interrupted - Needs attention — open conflicts (§10.6), deferred syncs, failed pushes or publishes
The unplanned section is the point. "38% of last week's closed items were unplanned, mostly interrupting PROJ-412" is the most useful number a team can have and almost nobody tracks it. It falls out of this schema for free — which is why discovered_during is required, not optional. timecard gets it as prose, not a percentage.
| Check | When | Failure |
|---|---|---|
Trailing newline on every .jsonl
|
pre-commit, PR | hard fail |
Every line parses; validates against event.schema.json
|
PR | hard fail |
fold() succeeds with zero orphans |
PR | warn |
roadmap.md matches regenerated output |
PR | hard fail |
| Compaction | nightly, main | hard fail, no partial write |
sync --report drift summary |
nightly | post as a comment; never fail |
PR-simulation integration suite (tests/test_integration.py) |
PR | hard fail |
Violating any of these is a bug, not a tradeoff:
- Every
.jsonlwrite ends in\n. - Nothing but the compactor rewrites a
.jsonlfile. - The compactor runs on main, in CI, in a commit of its own, and verifies
fold(new) == fold(old). - Nothing but
worklogwrites.jsonl— no editors, noecho >>. - Ticket push is idempotent, keyed by item ULID (the marker rule, §9.2).
- The core never branches on
ticketing.systembeyond checking fornone. -
docs/roadmap.mdis never hand-edited. - A plan in
docs/plans/is never edited or regenerated. Designs change by superseding. - A published status report is never regenerated. Corrections go in the next one.
- Missing edge tooling degrades to local-only; it never fails a command.
- Sync never silently discards a local change.
| Limitation | Why we accept it |
|---|---|
| Clock skew decides LWW ties. A dev 30s fast silently wins. | NTP-synced org, one team. actor + ts on every event make it debuggable. Fix in v2 with a per-item Lamport counter ordered by (lamport, ev) if it ever bites. |
| Closed items don't reconcile | Bounds sync scope. Manual --keys escape hatch exists. |
| One ticketing system per repo | Multi-system needs external to be an array. Not worth it yet. |
pull is polling, not webhooks |
Webhooks need a server. Polling on sync is best-effort by design. |
| Body text capped at 2 KB in the log | Keeps appends atomic under PIPE_BUF. Long prose lives in the plan doc. |
| Union merge can resurrect events from an abandoned branch | Rare; the fold is idempotent so the worst case is a stale field, visible in worklog history. |
-
Does
plan-nextwrite anything? Spec'd read-only. If it should mark the chosen itemsin_progress, that's a different skill (plan-start) — keep them separate. -
Grok build parity.Closed in 1.1. All settings live in.work/config.yml, so the agent file is policy-only andAGENTS.mdis a symlink toCLAUDE.md. Skills are discovered per-harness;bin/worklogis identical under both. -
Do epics sync as remote epics, or only as labels? Jira epics are a real type; GitHub Issues has no epic.
capabilities.typescovers it, but the roadmap grouping degrades on GitHub. Milestones as a fallback? -
Does the timecard need hours?Closed in 1.4. Narrative only — a sentence or two per day; no hours, no activity model.include_hoursstays false and is now dead config. The original question, for the record: Spec'dinclude_hours: false— narrative only. If a timecard is ever going to back an invoice, hours are the whole point, and the log has no duration data:in_progress→doneelapsed time counts overnight and weekends. Getting real hours means either aworklog start/stop(an activity model this spec doesn't have) or reading git commit timestamps as a proxy (rough, but free, and it's what actually happened). Decide before buildingtimecard— it's the difference between a narrative feature and a time-tracking subsystem. - Multi-repo. One roadmap across three repos is a real need and this spec has no answer. Probably a separate aggregator reading N logs. Note the timecard has the same problem, and worse: a consultant's week spans repos, so a per-repo timecard is the wrong unit.
-
Should
plan-capturepush immediately (push_on_capture: true) or batch until the plan completes? Immediate = visible sooner (fishbowl), more churn in Jira. - Does a superseded plan get unpublished from the wiki? Leaning no — leave it with a banner linking to its successor. Deleting the record of a rejected approach defeats the purpose of keeping plans.
bin/fold.py, bin/ulid.py, bin/worklog, and the test suite ship alongside this spec and are executable, not illustrative. 32 tests pass. The end-to-end union-merge scenario in §8.1 has been run against a real git repo: two branches editing the same item merged with no conflict, and both label additions plus both scalar edits survived.
Where prose and code disagree, the tests are the tiebreak — they encode §6 step by step.
tests/test_fold.py opens with four regressions, in this order and on purpose. Each is a real bug from a proposed implementation that produced plausible-looking output and silently corrupted state:
| Test | Guards | Naive implementation gives |
|---|---|---|
test_newest_ev_wins_regardless_of_file_position |
§6 step 3 — sort by ev
|
file-order fold: remote P0 lost, reports P1 |
test_cancelled_stays_cancelled |
§5.3 — close reads status from set
|
hardcodes done: abandoned work reports as shipped
|
test_add_and_del_labels |
§5.5 — set-valued fields |
add/del ignored: labels silently vanish |
test_snapshot_replaces_state_entirely |
§7, §6 step 4 | watermark ignored, snapshot merged instead of replacing |
Then: test_fold_is_deterministic_across_shuffles (all permutations of an event set fold identically — the property union merge depends on), test_corrupt_line_is_skipped_not_fatal (one fused line costs one line, not the log), test_orphan_is_flagged_not_invented, test_link_sets_external_not_ticket (src is provenance, external is identity — conflating them corrupts the field sync keys on), test_reopen_across_files_beats_done_snapshot.
tests/test_ulid.py::TestTheBugThisPrevents is a pair. Both run the §10.2 scenario — two devs poll the same Jira change, Rick edits locally in between, union merge brings both ingests in:
-
test_deterministic_ev_preserves_the_local_edit— dedupe collapses the ingests, Rick's P2 stands. -
test_random_ev_silently_reverts_the_local_edit— passes, and documents the failure. With a randomevper ingest (which is what putting the remote hash in a sidecar field likeev_remotegives you), dev B's duplicate sorts above Rick's edit and reverts him to P0. Nothing errors. Nothing warns.
That second test exists because this design keeps getting proposed. It's cheaper to have it fail loudly in CI than to argue about it.
Worth recording, because they're the argument for writing the code before finalizing the prose:
-
§5.2 required
itemon every event, butop: compactis log-level. A strict validator rejects the compactor's own output. Fixed:itemis required except forcompact. -
§7 step 6's
_movedtombstone was unnecessary. Areopenalways has a higherevthan thedonesnapshot it follows, so folding both files together byevhandles it with no extra machinery. Removed — and replaced with the thing actually needed: pruning stale snapshots fromdone.jsonl.
python3 tests/test_fold.py # 21 tests
python3 tests/test_ulid.py # 11 tests
git config core.hooksPath hooks # arms the pre-commit invariant checksCLAUDE.md policy only; AGENTS.md is a symlink to it
.gitattributes union merge for *.jsonl (§8.1)
.gitignore sync-state.json, changeset.json, results/
hooks/pre-commit trailing newline + schema + roadmap freshness + fold tests
bin/
worklog CLI. add/update/close/list/show/fold implemented;
plan-capture/sync/status/roadmap-render/compact stubbed
fold.py §6 reference implementation
ulid.py §5.2, §10.2 — incl. deterministic remote ev
tests/
test_fold.py
test_ulid.py
.work/
config.yml all settings
todo.jsonl empty
done.jsonl empty
schema/
docs/plans/ docs/status/ .claude/skills/
bin/worklog's append() is the only writer in the repo (invariant §15.4). Single os.write() under O_APPEND, always newline-terminated, body capped at MAX_BODY = 2048 — a module constant with a comment pointing at PIPE_BUF, deliberately not a config key.
$ worklog add "Extract auth middleware" --priority P1
$ git checkout -b alice && worklog update $A --add-label backend --status in_progress
$ git checkout -b bob && worklog update $A --add-label urgent --priority P0
$ git merge alice && git merge bob
Auto-merging .work/todo.jsonl <- no conflict
$ worklog show $A
priority: P0 <- bob's
status: in_progress <- alice's
labels: [backend, urgent] <- both
$ printf '{"ev":"01ZZZ",...}' >> .work/todo.jsonl # no trailing newline
$ git commit -m bad
worklog: .work/todo.jsonl has no trailing newline (invariant 15.1)
-> blocked, exit 1
Concurrent edits from two branches, zero conflicts, nothing lost — and the one failure union merge can't survive is caught at commit time.
-
Schema +Done — see Appendix A/B. No sync, no edge integration, useful on day one.fold+worklog add/update/close/show/list. -
Done. CI schema check still outstanding (the hook is local-only; CI must re-run it)..gitattributes, newline invariant, pre-commit hook. -
roadmap-render+ the CI freshness check. -
plan-capture+ theExitPlanModehook. - Compaction + its CI job.
-
Adapter contract + one adapter.Superseded by skills (§9, 1.4) — no adapter binaries ship; theticket-syncandwiki-publishskills replaced this step. -
Push-only sync. Ship it. Live with it for two weeks.Done —ticket-syncskill +worklog link, dogfooded against GitHub Issues. -
Pull + echo suppression + conflicts.Done —worklog ingest(deterministicev, §10.2),worklog conflict,worklog resolve <item> --field F --take local|remote(§10.6). -
Wiki adapter + publish.Done for github-wiki —wiki-publishskill +worklog wiki-add+ thepublished.jsonledger; other wiki systems are config away, not code away. -
status-report—dailyandweeklyfirst;timecardonly once open question 4 is settled.plan-next. - Typed adapter contract (§9.5) — dispatcher + fake adapter + generated adapters. In progress on this branch (
feature/typed-adapter-contract).
Steps 1–4 are a genuinely useful tool with no edge integration at all. If the project stalls there, it still paid for itself. Do not build sync before you've lived with the log.
- Roadmap
- Design-Doc · Code-Walkthrough
- Plan: Plan-ia-content-model
- Plan: Plan-ticket-sync-and-init-detection
- ADR-0001-event-log-fold-union-merge
- ADR-0002-skill-based-edges-typed-contract
- ADR-0003-green-gates-merge
- Index-Releases
- Latest snapshot: Roadmap-2026-07-29_v0.18.0-release
- Index-Status
- Index-Decisions