Track who filed a note (created_by)
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file-length / file length <= 250 lines (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
repo-contract / repo contract (push) Waiting to run

New optional note field created_by (kuhy|claude), stored like by/repo and
omitted when unset so older builds stay wire-compatible. App UI stamps kuhy;
MCP add_task and the CLI stamp claude. list/get/search output includes it.
Human notes sort above Claude's; Claude notes are hidden behind a per-device
eye toggle. CLI --backfill-created-by [--dry-run] classifies old notes by the
node that wrote created_at, writing only the created_by field.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Krzysztof Rudnicki 2026-11-10 14:14:22 +01:00
parent cc9789f530
commit 57019eec30
33 changed files with 786 additions and 68 deletions

View File

@ -4,49 +4,12 @@
/// exact-pinned dependency under the freshness gate.
library;
import 'package:todo/cli/cli_mode.dart';
import 'package:todo/data/note.dart';
export 'package:todo/cli/cli_mode.dart';
export 'package:todo/cli/cli_usage.dart';
/// What the CLI was asked to do.
enum CliMode {
/// Create a note and push it.
add,
/// Print every live note, one per line.
list,
/// Print one note in full.
get,
/// Tombstone one note and push the deletion.
delete,
/// Change one note's status and push it.
setStatus,
/// Set a task done with evidence and push it.
close,
/// Print the notes matching a query, as [list] does.
search,
/// Print usage.
help,
}
/// Thrown for a malformed command line; maps to exit code 2.
class UsageError implements Exception {
/// Creates a [UsageError] carrying [message].
UsageError(this.message);
/// What was wrong with the arguments.
final String message;
@override
String toString() => message;
}
/// The parsed command line.
class CliArgs {
/// Creates parsed arguments; see [parse].
@ -58,6 +21,7 @@ class CliArgs {
this.status,
this.dataDir,
this.newNote = false,
this.dryRun = false,
this.opts = (
repo: null,
query: null,
@ -89,6 +53,10 @@ class CliArgs {
opts[arg] = value();
case '--list':
modes.add(CliMode.list);
case '--backfill-created-by':
modes.add(CliMode.backfill);
case '--dry-run':
opts[arg] = 'true';
case '--search':
modes.add(CliMode.search);
opts[arg] = value();
@ -106,8 +74,8 @@ class CliArgs {
if (modes.contains(CliMode.help)) return const CliArgs(mode: CliMode.help);
if (modes.length > 1) {
throw UsageError(
'--list, --search, --get, --delete, --set-status and --close are '
'mutually exclusive',
'--list, --search, --get, --delete, --set-status, --close and '
'--backfill-created-by are mutually exclusive',
);
}
final mode = modes.isEmpty ? CliMode.add : modes.single;
@ -128,6 +96,7 @@ class CliArgs {
opts.containsKey('--new-note') &&
(opts['--repo'] != null ||
(throw UsageError('--new-note needs --repo'))),
dryRun: opts.containsKey('--dry-run'),
opts: (
repo: _repo(opts['--repo']),
query: _nonBlank(opts['--search'], '--search'),
@ -159,6 +128,9 @@ class CliArgs {
/// `--new-note`: the `--repo` task gets its own note.
final bool newNote;
/// Whether `--dry-run` was given: report what would change, change nothing.
final bool dryRun;
/// `--repo`, `--search`, `--done-check`, `--by` and `--evidence` values.
final ({
String? repo,
@ -183,6 +155,7 @@ class CliArgs {
),
'--evidence': ('to --close', {CliMode.close}),
'--new-note': (_adding, {CliMode.add}),
'--dry-run': ('to --backfill-created-by', {CliMode.backfill}),
};
static void _checkScopes(Iterable<String> flags, CliMode mode) {

View File

@ -38,6 +38,7 @@ class CliCommands {
repo: repo,
doneCheck: args.opts.doneCheck,
by: args.opts.by,
createdBy: 'claude',
);
await session.repository.upsert(note);
if (!await _push('note')) return 1;
@ -57,6 +58,7 @@ class CliCommands {
doneCheck: args.opts.doneCheck,
priority: args.priority,
by: args.opts.by,
createdBy: 'claude',
);
await _save(added.note);
if (!await _push('note')) return 1;
@ -79,6 +81,7 @@ class CliCommands {
doneCheck: args.opts.doneCheck,
priority: args.priority,
by: args.opts.by,
createdBy: 'claude',
);
await _save(added.note);
if (!await _push('note')) return 1;
@ -142,6 +145,35 @@ class CliCommands {
return 0;
}
/// Sets `created_by` where unset (see [NoteRepository.backfillCreatedBy]).
/// Prints one `<id>\t<value>\t<title>` line per note, then the totals. With
/// [dryRun] nothing is written or pushed, and peers are only read.
Future<int> backfillCreatedBy(
Set<String> claudeNodes, {
required bool dryRun,
}) async {
_report(await session.sync(push: !dryRun));
final plan = await session.repository.backfillCreatedBy(
claudeNodes,
dryRun: dryRun,
);
final titles = {
for (final n in await session.repository.listNotes()) n.id: n,
};
for (final MapEntry(:key, :value) in plan.entries) {
final note = titles[key];
final title = note == null ? '' : note.text.split('\n').first;
_out.writeln(
'${key.substring(0, key.length.clamp(0, 8))}\t$value\t$title',
);
}
final claude = plan.values.where((v) => v == 'claude').length;
final kuhy = plan.length - claude;
_out.writeln('${dryRun ? 'dry run' : 'set'}: claude $claude, kuhy $kuhy');
if (dryRun || plan.isEmpty) return 0;
return await _push('backfill') ? 0 : 1;
}
Future<Note> _pullAndFind(String ref) async {
_report(await session.sync(push: true));
return findNote(await session.repository.listNotes(), ref);

44
lib/cli/cli_mode.dart Normal file
View File

@ -0,0 +1,44 @@
/// What the CLI can be asked to do, and how a bad command line is reported.
library;
/// What the CLI was asked to do.
enum CliMode {
/// Create a note and push it.
add,
/// Print every live note, one per line.
list,
/// Print one note in full.
get,
/// Tombstone one note and push the deletion.
delete,
/// Change one note's status and push it.
setStatus,
/// Set a task done with evidence and push it.
close,
/// Print the notes matching a query, as [list] does.
search,
/// Set `created_by` on notes that lack it.
backfill,
/// Print usage.
help,
}
/// Thrown for a malformed command line; maps to exit code 2.
class UsageError implements Exception {
/// Creates a [UsageError] carrying [message].
UsageError(this.message);
/// What was wrong with the arguments.
final String message;
@override
String toString() => message;
}

View File

@ -80,6 +80,10 @@ Future<int> runCli(
CliMode.get => await cli.get(args.id!),
CliMode.setStatus => await cli.setStatus(args.id!, args.status!),
CliMode.close => await cli.close(args.id!, args.opts.evidence!),
CliMode.backfill => await cli.backfillCreatedBy(
claudeNodeIds(env),
dryRun: args.dryRun,
),
// Only delete is left: help returned above.
_ => await cli.delete(args.id!),
};

View File

@ -29,6 +29,19 @@ String defaultDataDir(Map<String, String> env) {
return p.join(base, 'todo-cli');
}
/// Node ids of the installs Claude files notes through: the MCP server and
/// the CLI under the same data root as [defaultDataDir]. Missing ones are
/// skipped.
Set<String> claudeNodeIds(Map<String, String> env) {
final cli = defaultDataDir(env);
return {
for (final dir in [cli, p.join(p.dirname(cli), 'todo-mcp')])
if (File(p.join(dir, CliSession.nodeIdFileName)) case final f
when f.existsSync())
f.readAsStringSync().trim(),
}..remove('');
}
/// The open state of one CLI invocation. Call [close] when done.
class CliSession {
/// Wraps already-open parts; [open] is the normal way in. Public so a test

View File

@ -25,6 +25,9 @@ A note with --repo is a repo task; one without is a plain idea.
--close <id> --evidence <s>
pull, set status done + evidence (commit sha + done
check exit code), push; refused without --evidence
--backfill-created-by [--dry-run]
pull, set created_by (claude|kuhy) on every note that
has none, push; --dry-run prints the plan only
--data-dir <dir> local log + node id (default
$XDG_DATA_HOME/todo-cli or ~/.local/share/todo-cli)
-h, --help this text

View File

@ -32,7 +32,7 @@ Note findNote(Iterable<Note> notes, String ref) {
return matches.single;
}
/// `<8-char id>\t<status>\t<priority>\t<repo or ->\t<title>`.
/// `<8-char id>\t<status>\t<priority>\t<repo or ->\t<title>\t<created_by>`.
String listLine(Note note) => [
if (note.id.length <= _minPrefix)
note.id
@ -42,6 +42,7 @@ String listLine(Note note) => [
note.priority.name,
note.repo ?? '-',
_title(note),
note.createdBy ?? 'kuhy',
].join('\t');
String _title(Note note) => note.text.split('\n').first;
@ -57,6 +58,7 @@ String noteDetail(Note note) => [
if (note.doneCheck case final v?) 'done_check: $v',
if (note.by case final v?) 'by: $v',
if (note.evidence case final v?) 'evidence: $v',
'created_by: ${note.createdBy ?? 'kuhy'}',
'---',
note.text,
].join('\n');

View File

@ -19,10 +19,14 @@ import 'package:shared_preferences/shared_preferences.dart';
/// sibling to `changesets/<nodeId>.json`.
const String kAdvancedModeRemotePath = 'settings/advancedMode';
/// App-wide, non-sync preferences (currently just `advancedMode`).
/// App-wide, non-sync preferences (`advancedMode`, `showClaudeNotes`).
class AppSettings {
/// Creates an [AppSettings] from its stored fields.
const AppSettings({required this.advancedMode, this.advancedModeUpdatedAt});
const AppSettings({
required this.advancedMode,
this.advancedModeUpdatedAt,
this.showClaudeNotes = false,
});
/// Whether the priority/status/template/mode chrome and the routine
/// save/sync status text are shown. Off by default: casual capture needs
@ -35,6 +39,11 @@ class AppSettings {
/// a value that has never round-tripped through Firebase.
final DateTime? advancedModeUpdatedAt;
/// Whether the notes list shows notes Claude filed. Per device: it lives
/// only in this device's preferences and is never mirrored to Firebase.
final bool showClaudeNotes;
static const _kShowClaude = 'app.showClaudeNotes';
static const _kAdvancedMode = 'app.advancedMode';
static const _kAdvancedModeUpdatedAt = 'app.advancedMode.updatedAt';
@ -44,6 +53,7 @@ class AppSettings {
final updatedAtMillis = prefs.getInt(_kAdvancedModeUpdatedAt);
return AppSettings(
advancedMode: prefs.getBool(_kAdvancedMode) ?? false,
showClaudeNotes: prefs.getBool(_kShowClaude) ?? false,
advancedModeUpdatedAt: updatedAtMillis == null
? null
: DateTime.fromMillisecondsSinceEpoch(updatedAtMillis),
@ -71,6 +81,7 @@ class AppSettings {
final updated = AppSettings(
advancedMode: value,
advancedModeUpdatedAt: updatedAt,
showClaudeNotes: showClaudeNotes,
);
if (client != null) {
try {
@ -89,6 +100,17 @@ class AppSettings {
return updated;
}
/// Persists the per-device Claude-notes choice; nothing leaves the device.
Future<AppSettings> withShowClaudeNotes({required bool value}) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_kShowClaude, value);
return AppSettings(
advancedMode: advancedMode,
advancedModeUpdatedAt: advancedModeUpdatedAt,
showClaudeNotes: value,
);
}
/// Pulls the remote value from [client] and, if it is newer than the
/// local one, applies it locally and returns the reconciled settings.
/// Returns this instance unchanged when [client] is null, there is
@ -100,9 +122,7 @@ class AppSettings {
try {
final remote = await client.getStringMap(kAdvancedModeRemotePath);
if (!remote.containsKey('value')) return this;
final remoteUpdatedMillis = int.tryParse(
remote['updatedAtMillis'] ?? '',
);
final remoteUpdatedMillis = int.tryParse(remote['updatedAtMillis'] ?? '');
if (remoteUpdatedMillis == null) return this;
final remoteUpdatedAt = DateTime.fromMillisecondsSinceEpoch(
remoteUpdatedMillis,
@ -110,6 +130,7 @@ class AppSettings {
final candidate = AppSettings(
advancedMode: remote['value'] == 'true',
advancedModeUpdatedAt: remoteUpdatedAt,
showClaudeNotes: showClaudeNotes,
);
final adopted = adopt(candidate);
if (identical(adopted, this)) return this;

View File

@ -0,0 +1,44 @@
/// Who filed a note, for notes written before `created_by` existed.
///
/// The `created_at` field's clock names the node that last wrote it. The MCP
/// and the CLI are the only nodes Claude writes through, so a note created
/// there carries one of their node ids; anything else came from kuhy's own
/// devices.
library;
import 'package:crdt_sync/crdt_sync.dart';
import 'package:todo/data/note_record.dart';
const _createdAt = 'created_at';
/// `claude` when [record]'s `created_at` was written by one of [claudeNodes],
/// else `kuhy`.
String classifyCreatedBy(Record record, Set<String> claudeNodes) {
final node = record.fields[_createdAt]?.$2.nodeId;
return node != null && claudeNodes.contains(node) ? 'claude' : 'kuhy';
}
/// Live records of [log] with no `created_by` yet, mapped to their value.
Map<String, String> planCreatedByBackfill(Log log, Set<String> claudeNodes) => {
for (final record in log.values)
if (!record.deleted &&
((record.fields[kCreatedByField]?.$1 as String?) ?? '').isEmpty)
record.id: classifyCreatedBy(record, claudeNodes),
};
/// [incoming], carrying [existing]'s `created_by` when [incoming] has none.
///
/// A store replaces a record wholesale, so an edit that does not know the
/// field (an import of an old backup) would otherwise drop it locally.
Record keepCreatedBy(Record incoming, Record? existing) {
final kept = existing?.fields[kCreatedByField];
if (kept == null || incoming.fields.containsKey(kCreatedByField)) {
return incoming;
}
return Record(
id: incoming.id,
fields: {...incoming.fields, kCreatedByField: kept},
deleted: incoming.deleted,
deletedHlc: incoming.deletedHlc,
);
}

View File

@ -76,7 +76,7 @@ enum Status {
}
}
/// The values the optional `by` field may hold: who owns a repo task.
/// The values the optional `by` and `created_by` fields may hold.
const Set<String> kNoteAuthors = {'claude', 'kuhy'};
/// An immutable idea/note record.
@ -99,6 +99,7 @@ class Note {
this.doneCheck,
this.by,
this.evidence,
this.createdBy,
});
/// Builds a [Note] from a raw CRDT query row.
@ -146,6 +147,12 @@ class Note {
/// Proof the task was closed (commit sha + the done check's exit code).
final String? evidence;
/// Who filed the note: one of [kNoteAuthors]; null (older notes) means kuhy.
final String? createdBy;
/// Whether Claude filed this note (hidden by default, sorted last).
bool get isClaudeMade => createdBy == 'claude';
/// Whether this note is a repo task rather than a plain idea.
bool get isRepoTask => repo != null;
@ -162,6 +169,7 @@ class Note {
String? doneCheck,
String? by,
String? evidence,
String? createdBy,
}) {
return Note(
id: id,
@ -174,6 +182,7 @@ class Note {
doneCheck: doneCheck ?? this.doneCheck,
by: by ?? this.by,
evidence: evidence ?? this.evidence,
createdBy: createdBy ?? this.createdBy,
);
}
}

View File

@ -37,6 +37,7 @@ class NoteFilter {
this.statuses = const {},
this.repo,
this.repoTasksOnly = false,
this.hideClaude = false,
this.createdFrom,
this.createdTo,
this.updatedFrom,
@ -58,6 +59,10 @@ class NoteFilter {
/// Only notes that have a repo (repo tasks). Ideas are excluded.
final bool repoTasksOnly;
/// Leave out notes Claude filed. A view preference rather than a user
/// facet, so it never counts toward [isEmpty] or [activeCount].
final bool hideClaude;
/// Inclusive lower bound (by calendar day) on the creation date.
final DateTime? createdFrom;
@ -104,6 +109,7 @@ class NoteFilter {
Set<Status>? statuses,
String? repo,
bool? repoTasksOnly,
bool? hideClaude,
DateTime? createdFrom,
DateTime? createdTo,
DateTime? updatedFrom,
@ -117,6 +123,7 @@ class NoteFilter {
statuses: statuses ?? this.statuses,
repo: repo ?? this.repo,
repoTasksOnly: repoTasksOnly ?? this.repoTasksOnly,
hideClaude: hideClaude ?? this.hideClaude,
createdFrom: clearCreated ? null : (createdFrom ?? this.createdFrom),
createdTo: clearCreated ? null : (createdTo ?? this.createdTo),
updatedFrom: clearUpdated ? null : (updatedFrom ?? this.updatedFrom),
@ -138,6 +145,7 @@ bool matchesFilter(Note note, NoteFilter filter) {
}
if (filter.repo != null && note.repo != filter.repo) return false;
if (filter.repoTasksOnly && note.repo == null) return false;
if (filter.hideClaude && note.isClaudeMade) return false;
if (filter.priorities.isNotEmpty &&
!filter.priorities.contains(note.priority)) {
return false;
@ -165,8 +173,17 @@ bool _withinDays(DateTime value, DateTime? from, DateTime? to) {
return true;
}
/// The comparator implementing [sort].
/// The comparator implementing [sort]: human-made notes first, Claude-made
/// ones after, and [sort] orders each group.
Comparator<Note> comparatorFor(NoteSort sort) {
final within = _within(sort);
return (a, b) {
if (a.isClaudeMade != b.isClaudeMade) return a.isClaudeMade ? 1 : -1;
return within(a, b);
};
}
Comparator<Note> _within(NoteSort sort) {
switch (sort) {
case NoteSort.createdDesc:
return (a, b) => b.createdAt.compareTo(a.createdAt);

View File

@ -14,6 +14,9 @@ const _doneCheck = 'done_check';
const _by = 'by';
const _evidence = 'evidence';
/// Record field naming who filed the note (`kuhy` or `claude`).
const kCreatedByField = 'created_by';
/// Record fields for [note], all at [hlc]. Unset task fields are omitted, so
/// an idea's record is exactly the v3 field set (and cannot clear a peer's).
Map<String, Field> noteFields(Note note, Hlc hlc) => {
@ -26,6 +29,7 @@ Map<String, Field> noteFields(Note note, Hlc hlc) => {
if (note.doneCheck case final v?) _doneCheck: (v, hlc),
if (note.by case final v?) _by: (v, hlc),
if (note.evidence case final v?) _evidence: (v, hlc),
if (note.createdBy case final v?) kCreatedByField: (v, hlc),
};
/// Reads [record] as a [Note], ignoring unknown fields.
@ -48,6 +52,7 @@ Note noteFromRecord(Record record) {
doneCheck: task(_doneCheck),
by: task(_by),
evidence: task(_evidence),
createdBy: task(kCreatedByField),
);
}

View File

@ -1,6 +1,7 @@
import 'dart:async';
import 'package:crdt_sync/crdt_sync.dart';
import 'package:todo/data/created_by_backfill.dart';
import 'package:todo/data/note.dart';
import 'package:todo/data/note_import.dart';
import 'package:todo/data/note_query.dart';
@ -114,9 +115,32 @@ class NoteRepository {
/// character-by-character autosave: it is cheap and idempotent. All fields
/// share one fresh monotonic clock (they are written together).
Future<void> upsert(Note note) async {
await _store.upsert(
Record(id: note.id, fields: noteFields(note, _store.nextHlc())),
await _put(Record(id: note.id, fields: noteFields(note, _store.nextHlc())));
}
Future<void> _put(Record record) =>
_store.upsert(keepCreatedBy(record, _store.get(record.id)));
/// Sets `created_by` on every live note that has none, from the node that
/// wrote its `created_at` (see [classifyCreatedBy]); returns id -> value.
///
/// Writes only that field, at a fresh clock, so no other field's clock
/// moves (a rewrite of `created_at` would erase the signal itself). With
/// [dryRun] nothing is written.
Future<Map<String, String>> backfillCreatedBy(
Set<String> claudeNodes, {
bool dryRun = false,
}) async {
final plan = planCreatedByBackfill(_store.snapshot(), claudeNodes);
if (dryRun || plan.isEmpty) return plan;
final hlc = _store.nextHlc();
await _store.replaceAll(
mergeLogs(_store.snapshot(), {
for (final e in plan.entries)
e.key: Record(id: e.key, fields: {kCreatedByField: (e.value, hlc)}),
}),
);
return plan;
}
/// Soft-deletes a note. The log keeps a sticky tombstone so the deletion
@ -142,10 +166,10 @@ class NoteRepository {
// the import wins locally; the clock decides whether it wins on *other*
// devices, and an import must not win there simply by being recent.
if (local == null) {
await _store.upsert(recordAtNoteTime(note, _nodeId));
await _put(recordAtNoteTime(note, _nodeId));
added++;
} else if (note.updatedAt.isAfter(local.updatedAt)) {
await _store.upsert(recordAtNoteTime(note, _nodeId));
await _put(recordAtNoteTime(note, _nodeId));
updated++;
} else {
skipped++;

View File

@ -71,6 +71,7 @@ Future<RepoAdd> addRepoTask(
String? doneCheck,
Priority priority = Priority.medium,
String? by,
String? createdBy,
}) async {
final section = repoSection(title, body);
final open = (await repository.listNotes(
@ -99,6 +100,7 @@ Future<RepoAdd> addRepoTask(
repo: repo,
doneCheck: composeDoneCheck(null, doneCheck),
by: by,
createdBy: createdBy,
),
appended: false,
);

View File

@ -77,6 +77,7 @@ Future<SplitAdd> addSeparateTask(
String? doneCheck,
Priority priority = Priority.medium,
String? by,
String? createdBy,
Future<bool> Function(Note note)? busy,
}) async {
final section = repoSection(title, body);
@ -113,6 +114,7 @@ Future<SplitAdd> addSeparateTask(
repo: repo,
doneCheck: composeDoneCheck(null, doneCheck),
by: by,
createdBy: createdBy,
),
kind: why == null ? SplitKind.added : SplitKind.fellBack,
why: why,

View File

@ -76,6 +76,7 @@ class TaskService {
doneCheck: a.optional('done_check'),
priority: a.priority() ?? Priority.medium,
by: 'claude',
createdBy: 'claude',
);
final verb = added.appended ? 'appended to' : 'added';
return (reply: await _save(added.note, verb), start: null);

View File

@ -35,6 +35,7 @@ Future<SplitReply> addSplit(
doneCheck: doneCheck,
priority: priority,
by: 'claude',
createdBy: 'claude',
busy: board == null ? null : (n) => board.running(n.id),
);
if (added.kind != SplitKind.merged) {

View File

@ -116,7 +116,8 @@ McpServer _server(TaskService tasks) {
tool(
'list_tasks',
'List repo tasks, one line each: 8-char id, status, priority, repo, '
'title. Default status "open" (todo + in_progress).',
'title, created_by (kuhy|claude). Default status "open" '
'(todo + in_progress).',
{
'repo': repo,
'status': JsonSchema.string(

View File

@ -92,6 +92,7 @@ class NotesMarkdown {
doneCheck: attrs['done_check'],
by: attrs['by'],
evidence: attrs['evidence'],
createdBy: attrs['created_by'],
),
);
}
@ -113,6 +114,7 @@ class NotesMarkdown {
('done_check', note.doneCheck),
('by', note.by),
('evidence', note.evidence),
('created_by', note.createdBy),
])
if (value != null) ' $name="${_escape(value)}"',
].join();

View File

@ -102,6 +102,7 @@ class CaptureDraft {
status: status,
createdAt: _createdAt!,
updatedAt: now,
createdBy: 'kuhy',
),
);
if (live()) lastSavedAt.value = now;

View File

@ -56,7 +56,7 @@ class _NotesListScreenState extends State<NotesListScreen> {
@override
void initState() {
super.initState();
_stream = widget.repository.watchNotes(sort: _sort, filter: _filter);
_stream = _watch();
}
@override
@ -69,9 +69,26 @@ class _NotesListScreenState extends State<NotesListScreen> {
/// Rebuilds the watch stream for the current sort + filter. Call only
/// from handlers that change those, never from [build].
void _applyState() {
setState(() {
_stream = widget.repository.watchNotes(sort: _sort, filter: _filter);
});
setState(() => _stream = _watch());
}
/// The query for the current sort + filter, minus Claude-made notes unless
/// this device's eye toggle is on.
Stream<List<Note>> _watch() => widget.repository.watchNotes(
sort: _sort,
filter: _filter.copyWith(
hideClaude: !widget.appSettings.value.showClaudeNotes,
),
);
Future<void> _toggleClaudeNotes() async {
final settings = widget.appSettings.value;
final next = await settings.withShowClaudeNotes(
value: !settings.showClaudeNotes,
);
if (!mounted) return;
widget.appSettings.value = next;
_applyState();
}
void _onSearchChanged(String value) {
@ -110,6 +127,16 @@ class _NotesListScreenState extends State<NotesListScreen> {
appBar: AppBar(
title: const Text('Notes'),
actions: [
// Eye, no label: open = Claude-made notes shown, slashed = hidden.
IconButton(
icon: Icon(
widget.appSettings.value.showClaudeNotes
? Icons.visibility
: Icons.visibility_off,
semanticLabel: 'Claude notes',
),
onPressed: _toggleClaudeNotes,
),
PopupMenuButton<NoteSort>(
tooltip: 'Sort',
icon: const Icon(Icons.sort),

View File

@ -10,6 +10,12 @@ The server is one more sync node: it reuses `lib/cli` (`CliSession`,
`todo-sync/notes/<node id>.json` plus `todo-sync/revs/<node id>`.
Code: `lib/mcp/`, entrypoint `mcp/todo_mcp.dart`, tests `test/mcp/`.
Output shapes: `list_tasks` and `search_tasks` print one tab-separated line per
task (`id8 status priority repo title created_by`); `get_task` prints
`key: value` lines (including `created_by`) then `---` and the text.
`created_by` is `claude` for notes filed through `add_task` or the CLI and
`kuhy` for everything else (a missing value reads as `kuhy`).
| What | Where |
|---|---|
| Firebase seed (read once) | `~/.config/todo-mcp/firebase_auth.json` |

View File

@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 1.0.65+1
version: 1.0.66+1
environment:
sdk: ^3.12.2

View File

@ -0,0 +1,21 @@
// A note typed into the app is stamped created_by kuhy.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'capture_screen_harness.dart';
void main() {
testWidgets('a captured note is stamped kuhy and keeps it on edit', (
tester,
) async {
final repo = await pumpCapture(tester);
await tester.enterText(find.byType(TextField).first, 'First line');
await tester.pump();
await tester.enterText(find.byType(TextField).first, 'First line, more');
await tester.pump();
final saved = (await repo.listNotes()).single;
expect(saved.text, 'First line, more');
expect(saved.createdBy, 'kuhy');
});
}

View File

@ -0,0 +1,85 @@
// created_by through the CLI: stamping, output, and the backfill command.
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'cli_harness.dart';
void main() {
late CliHarness h;
setUp(() => h = CliHarness());
tearDown(() => h.dispose());
test('a CLI add is stamped claude, repo task or idea', () async {
final idea = await h.add('Plain idea');
final task = await h.add('Fix gate', ['--repo', 'kuhyx/todo']);
for (final id in [idea, task]) {
expect(await h.run(['--get', id, '--data-dir', h.dir('b')]), 0);
expect(h.out.toString(), contains('created_by: claude'));
}
expect(await h.run(['--list']), 0);
expect(
h.out.toString().trim().split('\n'),
everyElement(endsWith('\tclaude')),
);
});
test('a --new-note add is stamped claude', () async {
final id = await h.add('Own note', ['--repo', 'kuhyx/todo', '--new-note']);
expect(await h.run(['--get', id, '--data-dir', h.dir('b')]), 0);
expect(h.out.toString(), contains('created_by: claude'));
});
test('a peer note without created_by reads as kuhy', () async {
h.seedPeer([fixtureNote('peer-note-1', 'From the phone')]);
expect(await h.run(['--get', 'peer-note-1']), 0);
expect(h.out.toString(), contains('created_by: kuhy'));
expect(await h.run(['--list']), 0);
expect(h.out.toString(), endsWith('From the phone\tkuhy\n'));
});
group('--backfill-created-by', () {
void seed() {
final mcp = Directory('${h.env['XDG_DATA_HOME']}/todo-mcp')
..createSync(recursive: true);
File('${mcp.path}/node_id').writeAsStringSync('mcp-node\n');
h.seedPeer([
fixtureNote('mcp-note-0001', 'Make a text-free', repo: 'kuhyx/a'),
], node: 'mcp-node');
h.seedPeer([fixtureNote('idea-note-0001', 'My idea')], node: 'phone');
}
test('--dry-run lists the plan and writes nothing', () async {
seed();
expect(await h.run(['--backfill-created-by', '--dry-run']), 0);
final lines = h.out.toString().trim().split('\n');
expect(lines, contains('mcp-note\tclaude\tMake a text-free'));
expect(lines, contains('idea-not\tkuhy\tMy idea'));
expect(lines.last, 'dry run: claude 1, kuhy 1');
expect(h.db.methods, isNot(contains('PUT')), reason: 'nothing pushed');
});
test('a real run sets it once, pushes it, and is then a no-op', () async {
seed();
expect(await h.run(['--backfill-created-by']), 0);
expect(h.out.toString().trim().split('\n').last, 'set: claude 1, kuhy 1');
expect(
await h.run(['--get', 'mcp-note-0001', '--data-dir', h.dir('b')]),
0,
);
expect(h.out.toString(), contains('created_by: claude'));
expect(
await h.run(['--get', 'idea-note-0001', '--data-dir', h.dir('b')]),
0,
);
expect(h.out.toString(), contains('created_by: kuhy'));
expect(await h.run(['--backfill-created-by']), 0);
expect(h.out.toString().trim(), 'set: claude 0, kuhy 0');
});
test('--dry-run is refused without the backfill flag', () async {
expect(await h.run(['--dry-run']), 2);
expect(h.err.toString(), contains('--dry-run only applies'));
});
});
}

View File

@ -60,10 +60,10 @@ class CliHarness {
return out.toString().trim();
}
/// Publishes [notes] as another device's log.
void seedPeer(List<Note> notes) {
db.files['todo-sync/notes/peer~2Ejson'] = logToJson({
for (final n in notes) n.id: NoteRepository.recordAtNoteTime(n, 'peer'),
/// Publishes [notes] as device [node]'s log.
void seedPeer(List<Note> notes, {String node = 'peer'}) {
db.files['todo-sync/notes/$node~2Ejson'] = logToJson({
for (final n in notes) n.id: NoteRepository.recordAtNoteTime(n, node),
});
}
}

View File

@ -121,7 +121,7 @@ void main() {
expect(await run(['--list', '--data-dir', dir('b')]), 0);
expect(
out.toString().trim(),
'${id.substring(0, 8)}\tin_progress\thigh\t-\tTitle',
'${id.substring(0, 8)}\tin_progress\thigh\t-\tTitle\tclaude',
);
expect(await run(['--get', id, '--data-dir', dir('b')]), 0);

View File

@ -41,7 +41,7 @@ void main() {
expect(await h.run(['--list', '--repo', 'kuhyx/todo']), 0);
expect(
h.out.toString(),
'${todo.substring(0, 8)}\ttodo\tmedium\tkuhyx/todo\ttodo todos\n',
'${todo.substring(0, 8)}\ttodo\tmedium\tkuhyx/todo\ttodo todos\tclaude\n',
);
expect(await h.run(['--list', '--status', 'done']), 0);
expect(h.out.toString(), isEmpty);

View File

@ -65,12 +65,15 @@ void main() {
);
});
test('listLine: 8-char id, status, priority, repo, title', () {
test('listLine: 8-char id, status, priority, repo, title, author', () {
expect(
listLine(notes[2].copyWith(status: Status.inProgress)),
'12345678\tin_progress\tmedium\t-\tFirst',
'12345678\tin_progress\tmedium\t-\tFirst\tkuhy',
);
expect(
listLine(notes[4]),
'99999999\ttodo\tmedium\tkuhyx/todo\tOnly\tkuhy',
);
expect(listLine(notes[4]), '99999999\ttodo\tmedium\tkuhyx/todo\tOnly');
expect(listLine(fixtureNote('abc', 'Short')), startsWith('abc\t'));
});
}

View File

@ -104,6 +104,12 @@ class FakeNoteRepository implements NoteRepository {
@override
Stream<int> watchCount() => _snapshots().map((n) => n.length);
@override
Future<Map<String, String>> backfillCreatedBy(
Set<String> claudeNodes, {
bool dryRun = false,
}) async => {};
@override
String get nodeId => 'fake-node';

View File

@ -0,0 +1,88 @@
// add_task stamps created_by claude; every read tool reports it.
import 'package:flutter_test/flutter_test.dart';
import 'package:mcp_dart/mcp_dart.dart';
import 'package:todo/data/note.dart';
import 'mcp_harness.dart';
void main() {
late McpHarness h;
late McpClient c;
setUp(() async {
h = McpHarness();
await h.start();
c = await h.connect();
});
tearDown(() => h.dispose());
test('add_task is stamped claude and list/get/search show it', () async {
final added = await h.call(c, 'add_task', {
'repo': 'kuhyx/todo',
'title': 'Fix gate',
});
final id = idIn(added);
final stored = (await h.session.repository.listNotes()).single;
expect(stored.createdBy, 'claude');
expect(
await h.call(c, 'get_task', {'id': id}),
contains('created_by: claude'),
);
for (final listed in [
await h.call(c, 'list_tasks', {}),
await h.call(c, 'search_tasks', {'query': 'gate'}),
]) {
expect(listed.trim(), endsWith('\tclaude'));
}
});
test('a separate new_note task is stamped claude too', () async {
await h.call(c, 'add_task', {
'repo': 'kuhyx/todo',
'title': 'Own note',
'new_note': true,
});
expect((await h.session.repository.listNotes()).single.createdBy, 'claude');
});
test('a record without created_by reads as kuhy', () async {
final at = DateTime.utc(2026, 11);
await h.session.repository.upsert(
Note(
id: 'human-task-0001',
text: 'Human task',
priority: Priority.medium,
status: Status.todo,
createdAt: at,
updatedAt: at,
repo: 'kuhyx/todo',
),
);
expect(
await h.call(c, 'get_task', {'id': 'human-task-0001'}),
contains('created_by: kuhy'),
);
expect(
(await h.call(c, 'list_tasks', {})).trim(),
endsWith('Human task\tkuhy'),
);
});
test('joining a human-made repo note keeps its author', () async {
final at = DateTime.utc(2026, 11);
await h.session.repository.upsert(
Note(
id: 'human-task-0001',
text: 'todo todos',
priority: Priority.medium,
status: Status.todo,
createdAt: at,
updatedAt: at,
repo: 'kuhyx/todo',
createdBy: 'kuhy',
),
);
await h.call(c, 'add_task', {'repo': 'kuhyx/todo', 'title': 'More'});
expect((await h.session.repository.listNotes()).single.createdBy, 'kuhy');
});
}

View File

@ -0,0 +1,181 @@
// created_by: record mapping, merge safety against older builds, list order,
// the hide filter and backfill classification.
import 'package:crdt_sync/crdt_sync.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:todo/data/created_by_backfill.dart';
import 'package:todo/data/note.dart';
import 'package:todo/data/note_repository.dart';
Note _note(
String id, {
String? createdBy,
Priority priority = Priority.medium,
DateTime? updated,
}) {
final at = updated ?? DateTime.utc(2026, 11);
return Note(
id: id,
text: 'note $id',
priority: priority,
status: Status.todo,
createdAt: at,
updatedAt: at,
createdBy: createdBy,
);
}
/// What an older build writes: no created_by, replacing the record locally.
Map<String, Field> _oldFields(Note n, Hlc hlc) => {
'text': (n.text, hlc),
'priority': (n.priority.value, hlc),
'status': (n.status.value, hlc),
'created_at': (n.createdAt.toIso8601String(), hlc),
'updated_at': (n.updatedAt.toIso8601String(), hlc),
};
Log _overTheWire(Log log) => logFromJson(logToJson(log));
void main() {
group('record mapping', () {
test('created_by is written only when set and read back', () {
final hlc = Hlc(wallTimeMs: 1, counter: 0, nodeId: 'n');
expect(
noteFields(_note('a'), hlc).containsKey('created_by'),
isFalse,
reason: 'an unset field keeps the record wire-identical',
);
final fields = noteFields(_note('a', createdBy: 'claude'), hlc);
expect(fields['created_by']!.$1, 'claude');
final back = noteFromRecord(Record(id: 'a', fields: fields));
expect(back.createdBy, 'claude');
expect(back.isClaudeMade, isTrue);
expect(_note('b', createdBy: 'kuhy').isClaudeMade, isFalse);
expect(_note('c').isClaudeMade, isFalse);
});
test('copyWith keeps createdBy unless replaced', () {
final n = _note('a', createdBy: 'claude');
expect(n.copyWith(text: 'x').createdBy, 'claude');
expect(n.copyWith(createdBy: 'kuhy').createdBy, 'kuhy');
});
});
group('merge safety', () {
test(
'an older build upsert (no created_by) does not wipe a peer value',
() async {
final current = await NoteRepository.openInMemory(nodeId: 'new');
await current.upsert(_note('a', createdBy: 'claude'));
final old = LogStore(persistence: MemoryPersistence(), nodeId: 'old');
await old.load();
await old.replaceAll(
mergeLogs(old.snapshot(), _overTheWire(current.exportLog())),
);
final edited = _note('a', updated: DateTime.utc(2026, 11, 2));
await old.upsert(
Record(id: 'a', fields: _oldFields(edited, old.nextHlc())),
);
expect(old.snapshot()['a']!.fields.containsKey('created_by'), isFalse);
await current.importLog(_overTheWire(old.snapshot()));
expect((await current.listNotes()).single.createdBy, 'claude');
// Pulling the other way restores it on the older device too.
await old.replaceAll(
mergeLogs(old.snapshot(), _overTheWire(current.exportLog())),
);
expect(old.snapshot()['a']!.fields['created_by']!.$1, 'claude');
},
);
test('upserting a note without createdBy keeps the stored value', () async {
final repo = await NoteRepository.openInMemory();
await repo.upsert(_note('a', createdBy: 'claude'));
await repo.upsert(_note('a', updated: DateTime.utc(2026, 11, 3)));
expect((await repo.listNotes()).single.createdBy, 'claude');
await repo.importNotes([_note('a', updated: DateTime.utc(2027))]);
expect((await repo.listNotes()).single.createdBy, 'claude');
});
});
group('order and hiding', () {
final claudeHigh = _note('c', createdBy: 'claude', priority: Priority.high);
final humanLow = _note('h', createdBy: 'kuhy', priority: Priority.low);
final legacy = _note('l', priority: Priority.medium);
test(
'human notes come first under every sort, then the old order',
() async {
final repo = await NoteRepository.openInMemory();
for (final n in [claudeHigh, humanLow, legacy]) {
await repo.upsert(n);
}
for (final sort in NoteSort.values) {
final ids = [for (final n in await repo.listNotes(sort: sort)) n.id];
expect(ids.last, 'c', reason: '$sort');
}
final byPriority = await repo.listNotes();
expect([for (final n in byPriority) n.id], ['l', 'h', 'c']);
},
);
test('hideClaude drops Claude-made notes and is not a user facet', () {
const filter = NoteFilter(hideClaude: true);
expect(matchesFilter(claudeHigh, filter), isFalse);
expect(matchesFilter(humanLow, filter), isTrue);
expect(matchesFilter(legacy, filter), isTrue);
expect(matchesFilter(claudeHigh, const NoteFilter()), isTrue);
expect(filter.isEmpty, isTrue);
expect(filter.activeCount, 0);
expect(filter.copyWith(query: 'x').hideClaude, isTrue);
expect(filter.copyWith(hideClaude: false).hideClaude, isFalse);
});
});
group('backfill', () {
Record rec(String id, String node, {String? createdBy, bool dead = false}) {
final hlc = Hlc(wallTimeMs: 5, counter: 0, nodeId: node);
return Record(
id: id,
fields: {...noteFields(_note(id, createdBy: createdBy), hlc)},
deleted: dead,
deletedHlc: dead ? hlc : null,
);
}
test('classifies by the node that wrote created_at', () {
const claude = {'mcp', 'cli'};
expect(classifyCreatedBy(rec('a', 'mcp'), claude), 'claude');
expect(classifyCreatedBy(rec('a', 'cli'), claude), 'claude');
expect(classifyCreatedBy(rec('a', 'phone'), claude), 'kuhy');
const bare = Record(id: 'x', fields: {});
expect(classifyCreatedBy(bare, claude), 'kuhy');
});
test('writes only unset, live records, and only that field', () async {
final repo = await NoteRepository.openInMemory(nodeId: 'cli');
await repo.importLog({
'a': rec('a', 'mcp'),
'b': rec('b', 'phone'),
'c': rec('c', 'phone', createdBy: 'claude'),
'd': rec('d', 'mcp', dead: true),
});
const claude = {'mcp', 'cli'};
final dry = await repo.backfillCreatedBy(claude, dryRun: true);
expect(dry, {'a': 'claude', 'b': 'kuhy'});
expect(repo.exportLog()['a']!.fields.containsKey('created_by'), isFalse);
final done = await repo.backfillCreatedBy(claude);
expect(done, dry);
final log = repo.exportLog();
expect(log['a']!.fields['created_by']!.$1, 'claude');
expect(log['b']!.fields['created_by']!.$1, 'kuhy');
expect(log['c']!.fields['created_by']!.$1, 'claude');
expect(log['d']!.fields.containsKey('created_by'), isFalse);
// created_at keeps its original clock: the signal is not destroyed.
expect(log['b']!.fields['created_at']!.$2.nodeId, 'phone');
expect(await repo.backfillCreatedBy(claude), isEmpty);
});
});
}

View File

@ -0,0 +1,100 @@
// Claude-made notes: hidden by default, an eye toggle shows them, the choice
// is remembered per device, and human notes always sort first.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:todo/data/app_settings.dart';
import 'package:todo/data/note.dart';
import 'package:todo/ui/notes_list_screen.dart';
import 'fake_note_repository.dart';
import 'notes_list_harness.dart';
final _seed = [
note(
'c',
'Claude chore',
priority: Priority.high,
).copyWith(createdBy: 'claude'),
note('h', 'Human idea', priority: Priority.low).copyWith(createdBy: 'kuhy'),
note('l', 'Legacy idea'),
];
Future<ValueNotifier<AppSettings>> _pump(
WidgetTester tester, {
AppSettings settings = const AppSettings(advancedMode: true),
}) async {
final repo = FakeNoteRepository(_seed, true);
addTearDown(repo.close);
final notifier = ValueNotifier(settings);
addTearDown(notifier.dispose);
await tester.pumpWidget(
MaterialApp(
home: NotesListScreen(repository: repo, appSettings: notifier),
),
);
await tester.pump();
return notifier;
}
Future<void> _tap(WidgetTester tester, IconData icon) async {
await tester.tap(find.byIcon(icon));
await tester.pump();
await tester.pump();
}
void main() {
setUp(() => SharedPreferences.setMockInitialValues({}));
testWidgets('Claude-made notes are hidden until the eye is tapped', (
tester,
) async {
await _pump(tester);
expect(find.text('Claude chore'), findsNothing);
expect(find.text('Human idea'), findsOneWidget);
expect(find.text('Legacy idea'), findsOneWidget);
expect(find.byIcon(Icons.visibility_off), findsOneWidget);
await _tap(tester, Icons.visibility_off);
expect(find.text('Claude chore'), findsOneWidget);
expect(find.byIcon(Icons.visibility), findsOneWidget);
await _tap(tester, Icons.visibility);
expect(find.text('Claude chore'), findsNothing);
});
testWidgets('shown, Claude notes sort below every human note', (
tester,
) async {
await _pump(
tester,
settings: const AppSettings(advancedMode: true, showClaudeNotes: true),
);
double y(String t) => tester.getTopLeft(find.text(t)).dy;
// The default 800x600 view lists one note per row, in display order; the
// high-priority Claude note still comes last.
expect(y('Claude chore'), greaterThan(y('Human idea')));
expect(y('Claude chore'), greaterThan(y('Legacy idea')));
});
testWidgets('the choice is persisted per device and reloaded', (
tester,
) async {
final notifier = await _pump(tester);
await _tap(tester, Icons.visibility_off);
expect(notifier.value.showClaudeNotes, isTrue);
final prefs = await SharedPreferences.getInstance();
expect(prefs.getBool('app.showClaudeNotes'), isTrue);
expect((await AppSettings.load()).showClaudeNotes, isTrue);
});
test('advancedMode and the toggle do not overwrite each other', () async {
final shown = await const AppSettings(
advancedMode: false,
).withShowClaudeNotes(value: true);
expect(shown.advancedMode, isFalse);
final both = await shown.withAdvancedMode(value: true);
expect(both.showClaudeNotes, isTrue);
expect(both.advancedMode, isTrue);
});
}