Note

ait note command for sending durable advisory context to an existing task

ait note

Append a note to an existing task’s ## Inbox section and commit it. A note carries context about work that already exists — a stale line number in the task’s body, a wider blast radius than it records, a decision made elsewhere that changes its approach. It is durable the moment the command succeeds, whether or not anyone is working on the task, and it surfaces the next time the task is picked.

A note is untrusted advisory input, never an instruction: it is one agent’s claim about a tree that may have moved. Nothing reads a note and acts on it automatically.

Most of the time you do not call this directly. The /aitask-note skill wraps it: it works out which task should receive the note, writes it, and — when an agent is working on that task and its runtime supports live delivery — also sends it to that session. Call ait note from scripts, or when you want the durable write on its own.

ait note 357 --from 349 --text "line numbers are stale"   # One-line note to t357
ait note 357 --from 349 --file notes.md                   # Body from a file
ait note 357 --from 349 --with-live --file notes.md       # Also report a live endpoint
ait note read 357 --by 357 --ids <note-id>                # Acknowledge a note
ait note 42 --project mobile --from 349 --text "..."       # Note to t42 in another repository
ait note read 42 --project mobile --by 42 --ids <note-id>  # Acknowledge it there

For a multi-line body, use --file - with a quoted heredoc, so the shell cannot expand anything inside it:

ait note 357 --from 349 --file - <<'NOTE_BODY'
Your task body cites parse_args() at line 88; as of this commit it is at line 142.
NOTE_BODY

Sending a note

CommandDescription
ait note <target-task-id> --from <id> --text <text>Append an inline note to the target’s inbox and commit the task file. Exit 0 = appended and committed, 1 = anything else (see Output)
ait note <target-task-id> --from <id> --file <path>The same, reading the body from a file. - reads stdin
OptionDescription
--from <id>The sending task, in this repository. Accepts 349, t349, 1657_2 or t1657_2 — never a <project>#<id> reference; to send from here to another repository use --project. Required
--text <text>The note body, inline. Give exactly one of --text and --file
--file <path>The note body, read from a file. - reads stdin
--with-liveAfter the note is committed, also report on a second output line whether the target is held by a live agent session on this machine. See Output
--project <name>The target task lives in another repository — the registered project <name>. See Sending to another repository
--from-project <name>This repository’s own project name, recorded with the sender. Needed only when this repository is registered under more than one name, and valid only together with --project

The target accepts the same forms as --from. The body is limited to 8192 bytes — a note is context, not a payload. NUL bytes are rejected and carriage returns are stripped. A task cannot note itself: when --from names the target, nothing is written and the result is NOTE_SELF:.

--with-live reports an endpoint, not a delivery. ait note never delivers anything to a running session itself; that step belongs to /aitask-note.

Sending to another repository

--project <name> sends the note to a task in a sibling project — another repository registered with ait projects. The target id then names a task in that repository; --from still names your task in this one. Everything else — the body options, --with-live, the output codes — is the same.

ait note 42 --project mobile --from 349 --file - <<'NOTE_BODY'
t349 renames the /v1/sessions endpoint; your API client in t42 still calls the old path.
NOTE_BODY

For a task you do not own, a note is usually the right tool: it adds context to that task’s ## Inbox without editing its description, metadata or status. If the content is itself work, create a task there instead (ait create --batch --project <name>).

The other repository does the write. ait note runs that repository’s own installed ait note, from inside it. The inbox lock, the task-data branch, the commit and the push are all that repository’s; nothing in your repository changes. The path in NOTE_APPENDED: and the other id-bearing lines is absolute, because it names a file in the other checkout.

How the project name is resolved. The name is looked up the same way as for ait projects resolve — a live tmux session for that project, the project registry, then an AITASKS_PROJECT_<name> environment variable — with one difference: ait note checks every source rather than taking the first match. If they point at two different checkouts, or a source could not be read, the note is refused instead of guessing where to write.

How the sender is recorded. The note stores from=<your-project>#t<id>, never a bare t<id> — a bare id would read as a task in the recipient’s own repository. Your project’s name comes from the project registry or an AITASKS_PROJECT_<name> variable pointing at this checkout; a tmux session name is never used for this. If the registry lists this checkout under several names, pass --from-project <name> to choose one. Your task must exist; it is checked before anything is written. from_verified=yes is recorded only when your session holds your task’s lock, exactly as for a local note — see Provenance.

Refusals. Each one is a single NOTE_ERROR:<reason> line (READ_ERROR:<reason> for ait note read), exits 1, and happens before anything is written in either repository:

ReasonMeaning
project-not-found:<name>No source knows that name. Register the project with ait projects add
project-stale:<name>The name points at a directory that is no longer an aitasks project
project-ambiguous:<name>The name reaches more than one checkout. The paths are printed on stderr
project-resolution-incomplete:<source>One of the lookup sources (tmux, registry, or resolver for an older resolver) could not be read, so a conflict cannot be ruled out
project-is-local:<name>The name is this repository — leave out --project
project-incompatible:<name>That repository’s framework does not support this command yet. Run ait upgrade there
source-unregisteredThis repository has no registered project name to record as the sender
source-ambiguous:<names>This repository is registered under several names. Pass --from-project with one of them
from-project-mismatch:<name>--from-project names a project that is not this repository
source-task-missing:<name>#t<id>The --from task does not exist in this repository
project-not-valid-with-migrate--project cannot be combined with --migrate
from-project-requires-project--from-project was given without --project. It only names this repository as the sender of a routed note, so on its own it is refused
duplicate-option:<flag> / missing-value:<flag>--project or --from-project given twice, or without a value

A target task that does not exist in the other repository is reported there, as NOTE_TARGET_MISSING:<id>.

Live delivery works the same way — the other repository’s live-endpoint lookup runs after its note is committed, and it only finds sessions on this machine.

Reading acknowledgement receipts

Displaying a note is not acknowledging it. When a task is picked, its unread notes are shown; only an explicit acknowledgement stops them from being shown again. ait note read records that acknowledgement as a read receipt in the same ## Inbox section.

CommandDescription
ait note read <task-id> --by <id> --ids <csv>Mark the listed notes read for <task-id>. Exit 0 = recorded or already acknowledged, 1 = anything else
OptionDescription
--by <id>The reader. Must be <task-id> itself — the reader is the session working on that task, and the task id is its only durable identity. Anything else is refused
--ids <csv>Comma-separated note ids, each naming a note present in that task’s inbox
--mode <mode>explicit (a person acknowledged — the default) or auto (an unattended run did). Recorded so that “no person read these” stays visible
--project <name>Acknowledge the notes of a task in another repository. Resolved and refused exactly as for sending; failures are READ_ERROR: lines. --by is still the target task’s own id, in that repository

Unread state is derived, never stored: a note is unread while its id appears in no valid receipt. There is no field to update, so receipts written at the same time on different machines simply combine, and a note acknowledged on one machine does not reappear on another once the task data syncs.

To see a task’s unread notes without acknowledging them, use the read-only query. It never writes a receipt, which is what makes it safe to run over a list of tasks:

./.aitask-scripts/aitask_query_files.sh inbox 357

It prints one INBOX_UNREAD: line per unread note, or NO_INBOX: / NO_UNREAD: when there is nothing to show.

A failed commit rolls a receipt back, but keeps a note. A note’s body is irreplaceable, and retrying would append it twice, so a note whose commit failed stays on disk and is reported as NOTE_APPENDED_UNCOMMITTED:. A receipt is bookkeeping that is cheap to recreate, and a receipt left on disk but uncommitted would hide a note on this machine with nothing durable behind it — so it is removed, and the note stays unread. If removing the receipt fails too, the receipt stays on disk, uncommitted, hiding the note on this machine. No automatic step can settle that, so it is reported on its own as READ_ERROR:rollback-failed:, and stderr names the block: remove it from the task file, or commit it.

How the inbox is stored

Notes and read receipts share one ## Inbox section in the task file. Everything in that section is written by the framework except a note’s body, which is free text from another session — the one part an untrusted writer controls. It is stored so that it cannot pass for anything else:

  • Every body line is stored behind a > | prefix. A block header is recognised only at the start of a line, so no line of a note body can parse as one. A note therefore cannot forge a read receipt — for itself or for any other note in the file — and cannot open a new section heading that would swallow what follows it. The prefix is added when the note is written, so every reader is protected without doing anything.
  • Receipts use the reserved name read. A note’s header always names its sender’s task, so no note can take that name.
  • A receipt that does not validate is skipped, so a malformed receipt can never hide a real note. The read-only inbox query reports any block that failed validation as INBOX_MALFORMED:, so it is not silently ignored either.

Change the section only through ait note.

Output

Every result is one line on stdout, prefixed with a code. Advisories and recovery hints go to stderr, so stdout stays parseable.

  • One line, always — unless you pass --with-live. With it, a second line follows, and only after NOTE_APPENDED:. Every other outcome skips the live lookup and still prints exactly one line.
  • The durable result is authoritative, and the exit status follows it alone. A LIVE_NONE: or LIVE_ERROR: line after NOTE_APPENDED: means the note was sent and live delivery was unavailable. The command exits 0. It is never a partial failure, and never a reason to send the note again.

Sending — the first line:

CodeLayerMeaning
NOTE_APPENDED:<note-id>|<path>writerAppended and committed. Exit 0
NOTE_APPENDED_UNCOMMITTED:<note-id>|<path>|<reason>writerAppended, but the commit failed. The note exists — do not retry, or it is appended twice. The recovery command is printed on stderr. Exit 1
NOTE_TARGET_MISSING:<id>writerNo task with that id. Nothing written. Exit 1
NOTE_SELF:<id>writerThe sender is the target. Nothing written. Exit 1
NOTE_ERROR:<reason>writerRefused before anything was appended — a bad argument, an oversized body, the inbox lock unavailable, or target-bundled when the target is an archived task packed into an archive bundle (a bundled record cannot be edited; a loose archived task is still accepted). Nothing written. Exit 1

The first two carry a note id; the other three mean no note exists. The two groups never overlap, so “was a note created?” can always be answered from stdout alone.

Live endpoint — the second line, only with --with-live and only after NOTE_APPENDED:. It is the resolver’s answer, passed through verbatim:

CodeLayerMeaning
LIVE_PANE:<%pane>|<session>:<@win>.<%pane>|<pid>|agent=<family>resolverThe target is held by a live agent session on this machine, in that tmux pane
LIVE_NONE:unlockedresolverNo lock record — nobody holds the task
LIVE_NONE:remote_hostresolverThe lock was taken on a different machine
LIVE_NONE:holder_deadresolverThe holding process is provably gone
LIVE_NONE:holder_unknownresolverThe holder’s liveness could not be established. Never treated as gone
LIVE_NONE:agent_unknownresolverThe task does not yet record which agent is implementing it — normal while it is still being planned
LIVE_NONE:agent_unsupported:<family>resolverThat agent family has no live-delivery adapter
LIVE_NONE:no_paneresolverThe holder is alive but owns no tmux pane that could be found, including when it runs on a different tmux server
LIVE_ERROR:usageresolverThe resolver was called with the wrong arguments
LIVE_ERROR:bad_task_idresolverThe task id is not well-formed
LIVE_ERROR:task_not_found:<id>resolverThe task has a lock record but no task file could be found for it
LIVE_ERROR:resolver_unavailablewriterThe resolver produced no usable answer at all — it is missing, not executable, or its output changed

LIVE_NONE: means “there is no live endpoint”, which is a real answer. LIVE_ERROR: means the lookup itself could not run. They are kept apart deliberately, so the two can never be confused. For what these outcomes mean day to day, see Task Notes.

When /aitask-note gets LIVE_PANE:, it sends the note to that session and reports LIVE_QUEUED: — queued, not read. The session picks the note up at its next step, which may be minutes away. A note is never reported as delivered or read.

Reading — ait note read:

CodeLayerMeaning
READ_RECORDED:<receipt-id>|<path>|<n-ids>writerReceipt committed. Exit 0
READ_RECORDED_UNPUSHED:<receipt-id>|<path>|<n-ids>writerReceipt committed but not pushed. Other checkouts may show these notes again until the task data syncs. Exit 0
READ_NOOP:<task-id>writerEvery listed note was already acknowledged. Nothing written. Exit 0
READ_TARGET_MISSING:<id>writerNo task with that id. Exit 1
READ_ERROR:<reason>writerNo receipt was written. The notes are still unread and will surface again. target-bundled means the task is packed into an archive bundle, where no receipt can be recorded. Exit 1
READ_ERROR:rollback-failed:<receipt-id>writerA failed commit could not be rolled back — the one outcome that needs a person to look at the task file. stderr names the block: remove it, or commit it. Exit 1

Provenance

Every note’s header line records where it was written, so a reader can check its claims against the exact tree they were made about.

FieldMeaning
fromThe sending task. A claim, not a proof. <project>#t<id> names a task in another repository
from_verified=yesWritten only when the sending session provably held the sender task’s lock at write time — same host, same process, same start time. Otherwise the field is left out, never written as no (the proof also fails for innocent reasons, such as a sending session that holds no task): its absence means “not proven”, never “disproved”. It says nothing about the note’s content
atWhen the note was written, in UTC
baseThe commit the sender’s code checkout was on — a full object id. For a note sent from another repository, the commit of the recipient’s checkout it was written into
base_branchThe branch that commit was on
base_mergebaseThe merge base with the repository’s primary branch, recorded only when the sender was on a different branch
dirtyyes if the sender’s working tree had uncommitted changes, no if it was clean
hostThe machine the note was written on

How base is captured, because it is the field a reader relies on most:

  • It comes from the code repository, never from the task file’s location. Task files live in a separate task-data worktree — aitasks/ links into .aitask-data — so reading git state from there would record the wrong commit.
  • It is captured before the note is appended and committed, so it describes the tree the sender was looking at.
  • For a note sent with --project, it is the recipient’s checkout — the tree the note was written into, and the one its reader can check out. The sender’s own repository is not recorded; a commit from there would not exist in the recipient’s history.
  • It is stored as a full object id. A short hash that is unique today can become ambiguous as the repository grows, which would break the one promise base makes, for exactly the oldest notes. Displays may abbreviate it; the stored value never is.
  • Two sentinel values exist. base=none means there was no git repository at all; dirty is then unknown, the only case where it could not be measured. base=unknown means the repository exists but has no commits yet; dirty is still measured.

What a base can and cannot date. It dates claims about the tree — line numbers, file contents — because you can check out that commit and look. It does not date claims about a moment — a git status reading, a running process, a lock someone held a second ago. dirty=yes is the warning that a claim like that may already be stale.

Migration

--migrate records a note that was written before the inbox existed — for example, context once passed between tasks by hand — keeping its original provenance instead of inventing new provenance.

OptionDescription
--migrateUse the migration path. --from is not allowed with it
--claimed-from <ref>The original sender — a local task id, or a cross-repo <project>#<id>
--claimed-at <date>The original note’s own date: YYYY-MM-DD or YYYY-MM-DDTHH:MM:SSZ
--base <oid>The commit the original note was written against — a full object id, or none / unknown
--base-branch <branch>That commit’s branch. Required with a real object id; not allowed with a sentinel

A migrated note always records migrated=yes, and never records from_verified, dirty or host. None of them was observed when the original was written, and filling them in now would invent provenance.


Next: Gates