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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions AGENTS-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ Write path:

Task fields:

- `id` (`tsq-<number>` root, `<parent>.<n>` child); legacy `tsq-<8 crockford base32 chars>` IDs remain valid
- `id` (new tasks mint flat random canonical `tsq-<8 lowercase crockford chars>` to avoid sync collisions; sequential `tsq-<number>` root and `<parent>.<n>` child ids remain valid/readable; legacy `tsq-<8 crockford base32 chars>` also valid; `--id` accepts any of these)
- `alias` (kebab-case slug generated from the creation title; stable across title edits)
- `kind` (`task|feature|epic`)
- `title`
Expand Down Expand Up @@ -111,7 +111,7 @@ Relation types:
Notes:

- For `find ready` and status-based `find` commands, `--full` is only valid with `--tree`. `--tree --full` keeps the full status set instead of applying the default tree status narrowing. `find search --full` remains valid without `--tree`.
- `--id <id>` accepts `tsq-<number>` or legacy `tsq-<8 crockford base32 chars>`.
- `--id <id>` accepts random canonical `tsq-<8 lowercase crockford chars>`, sequential root `tsq-<number>`, child `<parent>.<n>`, and legacy `tsq-<8 crockford base32 chars>` ids.
- Commands that accept a task ID also accept exact aliases and unique alias prefixes unless `--exact-id` is used.
- `tsq find similar "<text>"` shows ranked duplicate candidates with scores and reasons.
- `tsq create` refuses similar open/in-progress/blocked/deferred tasks unless `--force` is passed.
Expand Down Expand Up @@ -160,7 +160,7 @@ Notes:
- `tsq labels`
- `tsq history <id> [--limit <n>] [--type <event-type>] [--actor <name>] [--since <iso>]`
- `tsq root`
- `tsq sync [--no-push]`
- `tsq sync [--no-push]` — two-way sync: commit local changes, fetch remote (`origin`), merge, push; sets upstream on first push; `--no-push` commits locally only. See [docs/sync.md](./docs/sync.md) for conflict resolution.
- `tsq hooks install [--force]`
- `tsq hooks uninstall`
- `tsq migrate [--sync-branch|--worktree-name <name>]`
Expand Down Expand Up @@ -237,7 +237,9 @@ Error:

## Repo Conventions

- commit `.tasque/events.jsonl` and `.tasque/config.json`
- commit `.gitattributes` (registers `.tasque/events.jsonl merge=tasque-events`)
- in the main code worktree, commit `.tasque/config.json` (the sync-branch pointer)
- task data (`events.jsonl`, `specs/`, `config.json`) is committed by `tsq sync` inside the sync worktree; do not stage it manually
- do not commit `.tasque/state.json`
- do not create or edit `.tasque/tasks.jsonl`
- snapshots optional to commit (default local-only)
Expand Down
17 changes: 14 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,12 +160,18 @@ Git repos default to a dedicated sync worktree:

- `tsq init` configures `tsq-sync` by default and redirects data operations there.
- Fresh clones fetch the configured sync branch and create the worktree on first use.
- `tsq sync` pushes the sync branch to `origin` and sets upstream automatically when needed.
- `tsq sync` runs local-first two-way sync: commit local changes, fetch the `origin`
branch, merge, then push — setting upstream on first push.
- `tsq sync --no-push` commits locally without touching the network.
- Existing git repos with main-tree `.tasque` data migrate automatically when `tsq`
next resolves the project root.
- The main worktree keeps `.tasque/config.json` so `tsq` can find the sync branch.
- The sync worktree owns the canonical `.tasque/events.jsonl`, specs, snapshots, and cache.

See [`docs/sync.md`](./docs/sync.md) for the full sync workflow, conflict resolution,
and task id model. New tasks mint random canonical ids (`tsq-<8 crockford chars>`);
existing sequential (`tsq-42`) and child (`tsq-42.3`) ids stay valid.

Non-git directories use repo-local `.tasque/`:

- `events.jsonl`: canonical append-only event log
Expand All @@ -177,8 +183,13 @@ Non-git directories use repo-local `.tasque/`:
- `.gitignore`: local-only artifacts (`state.json`, `.lock`, `snapshots/`, temp files)
- `tasks.jsonl`: legacy state-cache name; read-only fallback when `state.json` is absent, removal target

Recommended commit policy:
Recommended commit policy — main code worktree:

- Commit `.tasque/events.jsonl` and `.tasque/config.json`
- Commit `.tasque/config.json` (pointer to the sync branch) and `.gitattributes`
- `.gitattributes` registers `.tasque/events.jsonl merge=tasque-events`; keep it committed so event merges stay correct
- Do not commit `.tasque/state.json`
- Do not create or edit `.tasque/tasks.jsonl`

Task data (`events.jsonl`, `specs/`, `config.json`) lives in the sync worktree and is
committed by `tsq sync`; do not stage it manually. `state.json`, `.lock`, and
`snapshots/` are gitignored there.
107 changes: 107 additions & 0 deletions docs/sync.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
# Task sync across machines

Tasque is local-first. `tsq sync` is the only path that touches the network,
and only when a remote is configured. It turns the local `.tasque/` data into a
git branch you can share between machines and collaborators.

## Where task data lives

In a git repo, `tsq init` moves task data off your code branch into a dedicated
**sync worktree** on a separate branch (default `tsq-sync`, configurable via
`--sync-branch` / `--worktree-name`). Your code branch keeps only
`.tasque/config.json` so Tasque can find the sync branch.

Main worktree:

- `.tasque/config.json` — sync-branch pointer/locator only.

Sync worktree:

- `.tasque/events.jsonl` — append-only event log (canonical source of truth).
- `.tasque/specs/<task-id>/spec.md` — task specs.
- `.tasque/config.json` — task-data config committed with the sync branch.
- `.tasque/state.json`, `.tasque/.lock`, `.tasque/snapshots/` — local-only,
gitignored, always rebuildable.

## What `tsq sync` does

`tsq sync` runs a local-first two-way sync in this order:

1. **Commit** local changes to the sync branch (events, specs, config).
2. **Fetch** the `origin` branch, if it exists.
3. **Merge** fetched changes. `events.jsonl` is merged by the
`tasque-events` driver (see below); other files use git's default merge.
4. **Push** the result back to the remote, setting upstream on first push.

`tsq sync --no-push` stops after step 1: local commit only, no network. The
bundled pre-push hook runs exactly this so a manual `git push` from the sync
worktree still commits pending task updates first.

When no remote/remote branch exists, `tsq sync` commits locally and reports that
push was skipped.
Comment on lines +40 to +41

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Doc inaccuracy: "no remote branch" does not result in a skipped push.

Per src/app/sync.rs::sync_worktree_locked, when origin exists but doesn't yet have the sync branch, tsq sync actively publishes it via push -u (and only falls back to the fetch/merge/retry loop if that publish push is rejected) — it does not skip the push. Only the "no origin remote at all" case skips push. Suggest splitting this sentence to avoid implying the sync branch is left unpublished when a remote exists.

📝 Proposed doc fix
-When no remote/remote branch exists, `tsq sync` commits locally and reports that
-push was skipped.
+When no `origin` remote exists, `tsq sync` commits locally and reports that push
+was skipped. When `origin` exists but has no sync branch yet, `tsq sync` publishes
+it and sets upstream automatically.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
When no remote/remote branch exists, `tsq sync` commits locally and reports that
push was skipped.
When no `origin` remote exists, `tsq sync` commits locally and reports that push
was skipped. When `origin` exists but has no sync branch yet, `tsq sync` publishes
it and sets upstream automatically.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/sync.md` around lines 40 - 41, The sync docs currently imply that a
missing remote branch causes push to be skipped, but `sync_worktree_locked`
still publishes the sync branch with `push -u` when `origin` exists and only
skips pushing when there is no `origin` remote at all. Update the sentence in
the `tsq sync` documentation to split these cases clearly, using
`sync_worktree_locked` and `push -u` as the reference points so the wording
matches the actual behavior.


## How conflicts resolve

Different files merge differently by design:

- `events.jsonl`: the `tasque-events` driver unions events by event id,
deduplicates identical events, and replay-validates the result. Independent
edits auto-merge. Same event id with divergent payloads, or an invalid merged
history, surfaces a structured `MERGE_*` error.
- `specs/<id>/spec.md`: git default 3-way text merge. Conflicting spec edits
leave conflict markers and require manual resolution.
- `config.json`: git default 3-way text merge. If both sides edit config,
resolve it manually.

The event merge is commutative for independent task creates: pulling A then B
yields the same `events.jsonl` as B then A, because events are deduplicated by
id (ULID `id`, legacy `event_id` accepted on read).

### Conflict UX

If a git merge conflict cannot be auto-resolved, `tsq sync` leaves the merge in
progress and returns a structured error with the conflicted paths and the sync
worktree path (`SYNC_MERGE_CONFLICT`). Resolve the conflicts in that worktree,
then re-run `tsq sync`: with no unmerged paths remaining it completes the merge
and pushes; if unmerged paths still exist it re-emits the conflict.

Spec conflicts in particular must be resolved by a human: open the marked
`spec.md`, pick the intended content, remove the `<<<<<<<` / `=======` / `>>>>>>>`
markers, save, then re-run `tsq sync`.

## Task IDs

New tasks get a flat random canonical id of the form `tsq-<8 lowercase crockford
chars>` (for example `tsq-7k3q9x2a`). Random ids avoid collisions when two
machines create tasks independently and later sync.

Existing ids keep working unchanged:

- Sequential root ids `tsq-<number>` (for example `tsq-42`) — still valid.
- Child ids `<parent>.<n>` (for example `tsq-42.3`) — still valid.
- Legacy 8-char ids `tsq-<8 crockford>` — still valid.

`--id <id>` on `tsq create` accepts any of these shapes. Commands that take a
task id accept the id, exact alias, or a unique alias prefix. An exact alias that
matches more than one task returns `TASK_ID_AMBIGUOUS` rather than guessing.

## Commit policy

For the **sync worktree** (task data), commit:

- `.tasque/events.jsonl`
- `.tasque/specs/` (specs are meant to sync)
- `.tasque/config.json`

Do not commit `.tasque/state.json`, `.tasque/.lock`, or `.tasque/snapshots/` —
they are gitignored and rebuildable. `tsq sync` handles this set automatically;
you should not stage task data manually.

For the **main code worktree**, commit:

- `.tasque/config.json` (the pointer to the sync branch)
- `.gitattributes` (it registers `.tasque/events.jsonl merge=tasque-events`)

`.gitattributes` matters: without the `tasque-events` driver entry, event merges
fall back to git's text merge and can corrupt the log. `tsq init` /
`tsq migrate` ensure the entry exists; keep it committed.
26 changes: 5 additions & 21 deletions src/app/service_create_update.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
use crate::app::service_types::{CreateBatchInput, CreateInput, ServiceContext, UpdateInput};
use crate::app::service_utils::{
must_resolve_existing, must_task, normalize_duplicate_title, unique_root_id,
};
use crate::app::service_utils::{must_resolve_existing, must_task, normalize_duplicate_title};
use crate::app::state::{load_projected_state, persist_projection};
use crate::domain::alias::allocate_alias;
use crate::domain::events::make_event;
use crate::domain::ids::{RootIdAllocator, is_valid_root_id, next_child_id};
use crate::domain::ids::{TaskIdAllocator, is_valid_root_id, make_task_id};
use crate::domain::labels::add_label;
use crate::domain::projector::apply_events;
use crate::domain::similarity::{
Expand Down Expand Up @@ -95,11 +93,7 @@ pub fn create(ctx: &ServiceContext, input: &CreateInput) -> Result<Task, TsqErro
input.force,
input.skip_duplicate_check,
)?;
let id = if let Some(parent) = parent_id.as_ref() {
next_child_id(&loaded.state, parent)
} else {
unique_root_id(&loaded.state, &input.title)?
};
let id = make_task_id(&loaded.state);
(id, parent_id)
};

Expand Down Expand Up @@ -397,7 +391,7 @@ pub fn create_batch(ctx: &ServiceContext, input: &CreateBatchInput) -> Result<Ve
let mut result_tasks: Vec<Task> = Vec::with_capacity(planned.len());
let mut working_state = loaded.state.clone();
let mut parent_stack: Vec<String> = Vec::new(); // depth → created/reused ID
let mut root_id_allocator: Option<RootIdAllocator> = None;
let mut id_allocator = TaskIdAllocator::new(&loaded.state);

// Track tasks created in this batch by (normalized_title, parent_id)
// so ensure can reuse earlier batch-created tasks for exact duplicates.
Expand Down Expand Up @@ -447,17 +441,7 @@ pub fn create_batch(ctx: &ServiceContext, input: &CreateBatchInput) -> Result<Ve
}
}

let id = if let Some(parent) = parent_id.as_ref() {
next_child_id(&working_state, parent)
} else {
if root_id_allocator.is_none() {
root_id_allocator = Some(RootIdAllocator::new(&loaded.state)?);
}
root_id_allocator
.as_mut()
.expect("root id allocator initialized")
.next_id()?
};
let id = id_allocator.next_id();

let description = if input.body_file.is_some() {
input.body_file.clone()
Expand Down
5 changes: 0 additions & 5 deletions src/app/service_utils.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
use crate::app::service_types::ListFilter;
use crate::domain::ids::make_root_id;
use crate::domain::resolve::resolve_task_id;
use crate::errors::TsqError;
use crate::types::{RelationType, State, Task, TaskStatus};
Expand All @@ -18,10 +17,6 @@ pub const DEFAULT_STALE_STATUSES: &[TaskStatus] = &[
TaskStatus::Deferred,
];

pub fn unique_root_id(state: &State, _title: &str) -> Result<String, TsqError> {
make_root_id(state)
}

pub fn must_task(state: &State, id: &str) -> Result<Task, TsqError> {
state
.tasks
Expand Down
Loading