Add new_note to add_task with merge and start

new_note files a separate note per repo; an exact section-title
match in another open note merges into it and starts it via the
board, unless that note is running, none or low priority.

Todo: 64d39ab5

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Krzysztof Rudnicki 2026-11-09 15:24:34 +01:00
parent 9a8e7aefd2
commit 75248e9cde
16 changed files with 799 additions and 9 deletions

View File

@ -57,6 +57,7 @@ class CliArgs {
this.priority = Priority.medium,
this.status,
this.dataDir,
this.newNote = false,
this.opts = (
repo: null,
query: null,
@ -79,6 +80,8 @@ class CliArgs {
}
switch (arg) {
case '--new-note':
opts[arg] = '';
case '-h' || '--help':
modes.add(CliMode.help);
case '--text' || '--priority' || '--status' || '--data-dir':
@ -121,6 +124,10 @@ class CliArgs {
? null
: _byName(Status.values, rawStatus, '--status', _statusName),
dataDir: opts['--data-dir'],
newNote:
opts.containsKey('--new-note') &&
(opts['--repo'] != null ||
(throw UsageError('--new-note needs --repo'))),
opts: (
repo: _repo(opts['--repo']),
query: _nonBlank(opts['--search'], '--search'),
@ -149,6 +156,9 @@ class CliArgs {
/// Overrides the default data dir (a second, independent node).
final String? dataDir;
/// `--new-note`: the `--repo` task gets its own note.
final bool newNote;
/// `--repo`, `--search`, `--done-check`, `--by` and `--evidence` values.
final ({
String? repo,
@ -172,6 +182,7 @@ class CliArgs {
{CliMode.add, CliMode.list, CliMode.search},
),
'--evidence': ('to --close', {CliMode.close}),
'--new-note': (_adding, {CliMode.add}),
};
static void _checkScopes(Iterable<String> flags, CliMode mode) {

View File

@ -4,6 +4,7 @@ import 'package:todo/cli/note_lookup.dart';
import 'package:todo/data/note.dart';
import 'package:todo/data/note_repository.dart';
import 'package:todo/data/repo_note.dart';
import 'package:todo/data/repo_note_split.dart';
import 'package:todo/sync/sync_service.dart';
import 'package:uuid/uuid.dart';
@ -47,6 +48,7 @@ class CliCommands {
Future<int> _addRepoTask(CliArgs args, String repo, String text) async {
_report(await session.sync(push: true));
final (:title, :body) = splitNote(text);
if (args.newNote) return await _addSeparate(args, repo, title, body);
final added = await addRepoTask(
session.repository,
repo: repo,
@ -63,6 +65,33 @@ class CliCommands {
return 0;
}
Future<int> _addSeparate(
CliArgs args,
String repo,
String title,
String body,
) async {
final added = await addSeparateTask(
session.repository,
repo: repo,
title: title,
body: body,
doneCheck: args.opts.doneCheck,
priority: args.priority,
by: args.opts.by,
);
await _save(added.note);
if (!await _push('note')) return 1;
if (added.kind == SplitKind.merged) {
_err.writeln(
'merged into the separate note with that title; '
'click Start on the board by hand',
);
}
_out.writeln(added.note.id);
return 0;
}
/// Prints [listLine] for each note matching the filters in [args].
Future<int> list(CliArgs args) async {
_report(await session.sync(push: false));

View File

@ -13,6 +13,8 @@ A note with --repo is a repo task; one without is a plain idea.
--repo <owner/name> repo task's repository, e.g. kuhyx/todo;
with --list/--search: show only that repo's tasks
--done-check <cmd> shell command, run from the repo root, exit 0 = done
--new-note with --repo: own note, not the shared one (an exact
title in another separate note merges into it)
--by <who> claude|kuhy
--list pull, then print live notes, one per line:
8-char id, status, priority, repo (- for none), title

View File

@ -12,6 +12,18 @@ const Set<Status> kOpenStatuses = {Status.todo, Status.inProgress};
/// The repo note's title.
String repoNoteTitle(String repo) => '${repo.split('/').last} todos';
/// A `new_note` task's own note title: `<name> todos: <title>`.
String separateNoteTitle(String repo, String title) =>
'${repoNoteTitle(repo)}: ${title.trim()}';
/// Whether [note] is a `new_note` note, which the shared add path and the
/// one-time merge leave alone.
bool isSeparateNote(Note note) {
final repo = note.repo;
return repo != null &&
splitNote(note.text).title.startsWith('${repoNoteTitle(repo)}: ');
}
/// Joins two checks with `&&`; either may be absent.
String? composeDoneCheck(String? older, String? newer) {
final a = (older ?? '').trim();
@ -49,7 +61,8 @@ int byAge(Note a, Note b) {
/// The note to save and whether it was appended.
typedef RepoAdd = ({Note note, bool appended});
/// Appends the task to [repo]'s oldest open note, or builds that note.
/// Appends the task to [repo]'s oldest open shared note, or builds that
/// note; separate (`new_note`) notes are never joined.
Future<RepoAdd> addRepoTask(
NoteRepository repository, {
required String repo,
@ -60,9 +73,9 @@ Future<RepoAdd> addRepoTask(
String? by,
}) async {
final section = repoSection(title, body);
final open = await repository.listNotes(
final open = (await repository.listNotes(
filter: NoteFilter(statuses: kOpenStatuses, repo: repo),
);
)).where((n) => !isSeparateNote(n)).toList();
if (open.isNotEmpty) {
final existing = (open..sort(byAge)).first;
return (

View File

@ -8,12 +8,15 @@ import 'package:todo/data/repo_note.dart';
typedef RepoMerge = ({Note kept, List<Note> removed});
/// A merge for every repo whose open notes are not yet one `<name> todos`
/// note; running it on its own result yields an empty plan.
/// note; running it on its own result yields an empty plan. Separate
/// (`new_note`) notes stay out of it.
List<RepoMerge> planRepoMerges(Iterable<Note> notes) {
final byRepo = <String, List<Note>>{};
for (final n in notes) {
final repo = n.repo;
if (repo != null && kOpenStatuses.contains(n.status)) {
if (repo != null &&
kOpenStatuses.contains(n.status) &&
!isSeparateNote(n)) {
byRepo.putIfAbsent(repo, () => []).add(n);
}
}

View File

@ -0,0 +1,112 @@
/// `new_note`: a repo task in its own open note, `<name> todos: <title>`,
/// so it can close on its own done_check. A title that already heads an open
/// section of another separate note of the repo merges into that note
/// instead (it keeps its id); the shared `<name> todos` note never counts.
library;
import 'package:todo/data/note.dart';
import 'package:todo/data/note_repository.dart';
import 'package:todo/data/repo_note.dart';
import 'package:uuid/uuid.dart';
/// How a separate add landed.
enum SplitKind {
/// A new separate note.
added,
/// Appended to the separate note that already had the title.
merged,
/// A new separate note because the duplicate's note was busy.
fellBack,
}
/// The note to save, how it landed, and why it fell back.
typedef SplitAdd = ({Note note, SplitKind kind, String? why});
final _heading = RegExp(r'^##\s+(\[x\]\s+)?(.*)$');
/// The open `## title` headings of [text], outside code fences.
Iterable<String> openSections(String text) sync* {
var fenced = false;
for (final line in text.split('\n')) {
if (line.trimLeft().startsWith('```')) fenced = !fenced;
final m = fenced ? null : _heading.firstMatch(line);
if (m != null && m.group(1) == null) yield m.group(2)!.trim();
}
}
/// The separate open note of [repo] with an open section titled [title].
Note? duplicateTarget(Iterable<Note> open, String repo, String title) {
final hits =
open
.where((n) => n.repo == repo && isSeparateNote(n))
.where((n) => openSections(n.text).contains(title.trim()))
.toList()
..sort(byAge);
return hits.isEmpty ? null : hits.first;
}
/// Why a merged task must not be started, or null when it may be.
String? startBlock(String? doneCheck, String body) {
final check = (doneCheck ?? '').trim().toLowerCase();
if (check.isEmpty || check == 'none') return 'done_check is none';
final first = body
.split('\n')
.firstWhere((l) => l.trim().isNotEmpty, orElse: () => '');
return first.trim().toLowerCase().startsWith('low priority')
? 'marked low priority'
: null;
}
/// Adds the task as its own note, or merges it into the duplicate's note
/// unless [busy] says a board session is working on it.
Future<SplitAdd> addSeparateTask(
NoteRepository repository, {
required String repo,
required String title,
String body = '',
String? doneCheck,
Priority priority = Priority.medium,
String? by,
Future<bool> Function(Note note)? busy,
}) async {
final section = repoSection(title, body);
final open = await repository.listNotes(
filter: NoteFilter(statuses: kOpenStatuses, repo: repo),
);
final target = duplicateTarget(open, repo, title);
String? why;
if (target != null) {
if (busy == null || !await busy(target)) {
return (
note: target.copyWith(
text: '${target.text}\n\n$section',
doneCheck: composeDoneCheck(target.doneCheck, doneCheck),
priority: maxPriority(target.priority, priority),
),
kind: SplitKind.merged,
why: null,
);
}
why =
'not merged into ${target.id.substring(0, 8)}: '
'a board session is running on it';
}
final now = DateTime.now();
return (
note: Note(
id: const Uuid().v4(),
text: '${separateNoteTitle(repo, title)}\n\n$section',
priority: priority,
status: Status.todo,
createdAt: now,
updatedAt: now,
repo: repo,
doneCheck: composeDoneCheck(null, doneCheck),
by: by,
),
kind: why == null ? SplitKind.added : SplitKind.fellBack,
why: why,
);
}

100
lib/mcp/board_client.dart Normal file
View File

@ -0,0 +1,100 @@
/// The todoImplementer board as `add_task new_note` sees it: whether a
/// note has a session running, and a Start click on a merged task. Start
/// goes through the board's own page (CSRF meta tag, same Origin), so the
/// board needs no new endpoint. Failures come back as reasons, never throws:
/// a board that is down must not lose the task that was just saved.
library;
import 'dart:convert';
import 'package:http/http.dart' as http;
/// Where the board listens (loopback only).
final Uri kBoardBase = Uri.parse('http://127.0.0.1:8786');
/// A Start attempt: the item started, or why not.
typedef StartResult = ({bool ok, String? reason, String? item});
final _csrf = RegExp(r'<meta\s+name="todoimpl-csrf"\s+content="([^"]+)"');
/// Talks to the board over [client] at [base].
class BoardClient {
/// [client] is injectable so tests need no network.
BoardClient({http.Client? client, Uri? base})
: _http = client ?? http.Client(),
base = base ?? kBoardBase;
final http.Client _http;
/// The board's origin; also the `Origin` header of a POST.
final Uri base;
static const _timeout = Duration(seconds: 5);
Future<List<Map<String, dynamic>>> _cards(String noteId) async {
final res = await _http.get(base.resolve('/api/board')).timeout(_timeout);
if (res.statusCode != 200) {
throw http.ClientException('GET /api/board ${res.statusCode}');
}
final tasks = _json(res)['tasks'];
final prefix = '${noteId.substring(0, 8)}-';
return [
for (final t in (tasks as List).cast<Map<String, dynamic>>())
if ((t['id']! as String).startsWith(prefix)) t,
];
}
/// Whether any item of the note is in the board's Running column; false
/// when the board cannot be reached.
Future<bool> running(String noteId) async {
try {
return (await _cards(noteId)).any((c) => c['column'] == 'running');
} on Object {
return false;
}
}
/// Clicks Start on the note's last startable item titled [title].
Future<StartResult> start(String noteId, String title) async {
try {
final card = (await _cards(noteId)).reversed
.where((c) => c['title'] == title && _startable(c))
.firstOrNull;
if (card == null) return _no('no startable item');
final page = await _http.get(base).timeout(_timeout);
final token = _csrf.firstMatch(page.body)?.group(1);
if (token == null) return _no('no CSRF token on the board page');
final id = card['id']! as String;
final res = await _http
.post(
base.resolve('/api/task/$id/start'),
headers: {'Origin': base.origin, 'X-Todoimpl-Token': token},
)
.timeout(_timeout);
if (res.statusCode == 200) return (ok: true, reason: null, item: id);
return _no('board refused start: ${res.statusCode} ${_message(res)}');
} on Object catch (e) {
return _no('board unreachable: $e');
}
}
static bool _startable(Map<String, dynamic> card) => (card['actions'] as List)
.cast<Map<String, dynamic>>()
.any((a) => a['id'] == 'start' && a['enabled'] == true);
static String _message(http.Response res) {
try {
final error = _json(res)['error'];
return (error as Map<String, dynamic>)['message'] as String;
} on Object {
return res.body;
}
}
// The board's JSON is UTF-8 whatever the Content-Type says.
static Map<String, dynamic> _json(http.Response res) =>
jsonDecode(utf8.decode(res.bodyBytes)) as Map<String, dynamic>;
static StartResult _no(String reason) =>
(ok: false, reason: reason, item: null);
}

View File

@ -3,6 +3,7 @@ import 'dart:io';
import 'package:http/http.dart' as http;
import 'package:path/path.dart' as p;
import 'package:todo/cli/cli_session.dart';
import 'package:todo/mcp/board_client.dart';
import 'package:todo/mcp/task_service.dart';
import 'package:todo/mcp/todo_mcp_server.dart';
@ -30,7 +31,7 @@ Future<Future<void> Function()?> startTodoMcp(
);
await session.sync(push: true);
final server = await TodoMcpServer.start(
tasks: TaskService(session),
tasks: TaskService(session, board: BoardClient()),
tokens: tokens,
port: int.tryParse(env['TODO_MCP_PORT'] ?? '') ?? kTodoMcpPort,
);

View File

@ -4,13 +4,17 @@ import 'package:todo/cli/note_lookup.dart';
import 'package:todo/data/note.dart';
import 'package:todo/data/note_repository.dart';
import 'package:todo/data/repo_note.dart';
import 'package:todo/mcp/board_client.dart';
import 'package:todo/mcp/task_split.dart';
/// The six task tools over one persistent sync node; calls run one at a time.
class TaskService {
/// Serves [_session]'s notes; every call pulls first, writes then push.
TaskService(this._session);
/// [board] is the todoImplementer board `new_note` checks and starts on.
TaskService(this._session, {this._board});
final CliSession _session;
final BoardClient? _board;
Future<void> _tail = Future.value();
Future<String> _serial(Future<String> Function() body) {
@ -46,6 +50,18 @@ class TaskService {
final repo = a.repo(required: true)!;
final title = a.required('title');
await _pull();
if (a.flag('new_note')) {
return await addSplit(
_session.repository,
_board,
_save,
repo: repo,
title: title,
body: a.optional('body') ?? '',
doneCheck: a.optional('done_check'),
priority: a.priority() ?? Priority.medium,
);
}
final added = await addRepoTask(
_session.repository,
repo: repo,
@ -139,6 +155,12 @@ final Map<String, Status> _statuses = {
extension type _Args(Map<String, dynamic> _args) {
bool has(String name) => _args.containsKey(name);
bool flag(String name) => switch (_args[name]) {
null || false => false,
true => true,
_ => throw FormatException('$name: must be a boolean'),
};
String? optional(String name) {
final value = _args[name];
if (value == null) return null;

48
lib/mcp/task_split.dart Normal file
View File

@ -0,0 +1,48 @@
/// `add_task` with `new_note: true`: the separate note, the guard merge and
/// the board Start that follows a merge, as one reply text.
library;
import 'package:todo/data/note.dart';
import 'package:todo/data/note_repository.dart';
import 'package:todo/data/repo_note_split.dart';
import 'package:todo/mcp/board_client.dart';
/// Saves [note] and returns `<verb> <id>` plus its list line.
typedef SaveNote = Future<String> Function(Note note, String verb);
/// Adds a separate task, merging into a duplicate's note when it is idle,
/// and clicks Start on the board after a merge that may run.
Future<String> addSplit(
NoteRepository repository,
BoardClient? board,
SaveNote save, {
required String repo,
required String title,
required String body,
required String? doneCheck,
required Priority priority,
}) async {
final added = await addSeparateTask(
repository,
repo: repo,
title: title,
body: body,
doneCheck: doneCheck,
priority: priority,
by: 'claude',
busy: board == null ? null : (n) => board.running(n.id),
);
if (added.kind != SplitKind.merged) {
final reply = await save(added.note, 'added');
return added.why == null ? reply : '$reply\n${added.why}';
}
final reply = await save(added.note, 'merged into');
final block = startBlock(doneCheck, body);
if (block != null) return '$reply\nstart: skipped ($block)';
final start = board == null
? (ok: false, reason: 'no board configured', item: null)
: await board.start(added.note.id, title.trim());
if (start.ok) return '$reply\nstart: ok ${start.item}';
return '$reply\nstart: {ok: false, reason: "${start.reason}"}; '
'the merge is saved, click Start on the board by hand';
}

View File

@ -96,13 +96,19 @@ McpServer _server(TaskService tasks) {
"Add a task to the repo's single open note (by: claude): appended as a "
'"## title" section, or the note is created. Returns "added <full id>" '
'or "appended to <full id>", then its list line. Use when you find '
'work you will not finish now.',
'work you will not finish now. With new_note: true the task gets '
'its own note ("added"), or, when another separate note of the repo '
'already has that exact title, "merged into" that note, followed by '
'a "start:" line for the todoImplementer board.',
{
'repo': repo,
'title': JsonSchema.string(description: 'One-line title.'),
'body': JsonSchema.string(description: 'Markdown: what/where/verify.'),
'done_check': doneCheck,
'priority': priority,
'new_note': JsonSchema.boolean(
description: 'Own note instead of joining the shared one.',
),
},
['repo', 'title'],
tasks.add,

View File

@ -0,0 +1,40 @@
// `--repo --new-note`: the task gets its own note; an exact title in another
// separate note merges into it. The CLI never talks to the board. ASCII
// titles: FakeRtdb encodes its responses as Latin-1.
import 'package:flutter_test/flutter_test.dart';
import 'package:todo/cli/cli_args.dart';
import 'cli_harness.dart';
void main() {
late CliHarness h;
setUp(() => h = CliHarness());
tearDown(() => h.dispose());
Future<String> add(String text, [List<String> more = const []]) =>
h.add(text, ['--repo', 'kuhyx/herosi', ...more]);
test('C1: --new-note opens a second note; a duplicate merges', () async {
final shared = await add('Old\n\nx');
final split = await add('Mlody\n\nwhat', ['--new-note']);
expect(split, isNot(shared));
final again = await add('Mlody\n\nmore', ['--new-note']);
expect(again, split);
expect(h.err.toString(), contains('merged into'));
expect(h.err.toString(), contains('click Start on the board by hand'));
expect(await h.run(['--list', '--repo', 'kuhyx/herosi']), 0);
expect(h.out.toString().trim().split('\n'), hasLength(2));
expect(await h.run(['--get', split]), 0);
expect(h.out.toString(), contains('herosi todos: Mlody'));
});
test('--new-note needs --repo and an add', () {
expect(() => CliArgs.parse(['--new-note']), throwsA(isA<UsageError>()));
expect(
() => CliArgs.parse(['--list', '--repo', 'a/b', '--new-note']),
throwsA(isA<UsageError>()),
);
expect(CliArgs.parse(['--repo', 'a/b', '--new-note']).newNote, isTrue);
expect(CliArgs.parse(['--repo', 'a/b']).newNote, isFalse);
});
}

View File

@ -0,0 +1,130 @@
// BoardClient: scrape the CSRF meta, find the startable item by title,
// POST start; every failure is a reason, never an exception.
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:todo/mcp/board_client.dart';
const _id = 'cccccccc-1111-2222-3333-444444444444';
Map<String, dynamic> _card(
String id,
String title, {
String column = 'backlog',
bool start = true,
}) => {
'id': id,
'title': title,
'column': column,
'actions': [
{'id': 'start', 'enabled': start},
],
};
class _Board {
_Board(this.cards);
final List<Map<String, dynamic>> cards;
final posts = <http.Request>[];
int startStatus = 200;
int boardStatus = 200;
String startBody = '{"ok":false,"error":{"message":"nope"}}';
String page = '<meta name="todoimpl-csrf" content="tok-1">';
BoardClient client() => BoardClient(
client: MockClient((req) async {
if (req.method == 'POST') {
posts.add(req);
return http.Response(startBody, startStatus);
}
if (req.url.path == '/api/board') {
return http.Response.bytes(
utf8.encode(jsonEncode({'tasks': cards})),
boardStatus,
);
}
return http.Response(page, 200);
}),
);
}
void main() {
test('C6: starts the last startable item with that title', () async {
final b = _Board([
_card('cccccccc-0', 'Młody'),
_card('cccccccc-1', 'Other'),
_card('cccccccc-2', 'Młody'),
_card('dddddddd-0', 'Młody'),
]);
final r = await b.client().start(_id, 'Młody');
expect(r, (ok: true, reason: null, item: 'cccccccc-2'));
final post = b.posts.single;
expect(
post.url.toString(),
'http://127.0.0.1:8786/api/task/cccccccc-2/start',
);
expect(post.headers['X-Todoimpl-Token'], 'tok-1');
expect(post.headers['Origin'], 'http://127.0.0.1:8786');
expect(post.body, isEmpty);
});
test(
'C7: no startable item, refusal and missing token are reasons',
() async {
final b = _Board([_card('cccccccc-0', 'Młody', start: false)]);
expect(
(await b.client().start(_id, 'Młody')).reason,
'no startable item',
);
final refused = _Board([_card('cccccccc-0', 'Młody')])..startStatus = 409;
final r = await refused.client().start(_id, 'Młody');
expect(r.ok, isFalse);
expect(r.reason, 'board refused start: 409 nope');
final blank = _Board([_card('cccccccc-0', 'Młody')])..page = '<html>';
expect(
(await blank.client().start(_id, 'Młody')).reason,
'no CSRF token on the board page',
);
},
);
test('C7: a board that is down gives a reason, not a throw', () async {
final down = BoardClient(
client: MockClient((_) async => throw http.ClientException('refused')),
);
final r = await down.start(_id, 'Młody');
expect(r.ok, isFalse);
expect(r.reason, startsWith('board unreachable'));
expect(await down.running(_id), isFalse);
});
test('C5: running is true only for a running item of that note', () async {
final b = _Board([
_card('cccccccc-0', 'A'),
_card('dddddddd-0', 'A', column: 'running'),
]);
expect(await b.client().running(_id), isFalse);
b.cards.add(_card('cccccccc-1', 'B', column: 'running'));
expect(await b.client().running(_id), isTrue);
});
test('a broken board and a non-JSON refusal are reasons too', () async {
final b = _Board([_card('cccccccc-0', 'Młody')])..boardStatus = 500;
expect(
(await b.client().start(_id, 'Młody')).reason,
allOf(startsWith('board unreachable'), contains('GET /api/board 500')),
);
final raw = _Board([_card('cccccccc-0', 'Młody')])
..startStatus = 502
..startBody = 'Bad Gateway';
expect(
(await raw.client().start(_id, 'Młody')).reason,
'board refused start: 502 Bad Gateway',
);
});
test('the default client targets the loopback board', () {
expect(BoardClient().base.toString(), 'http://127.0.0.1:8786');
});
}

View File

@ -10,6 +10,7 @@ import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:mcp_dart/mcp_dart.dart';
import 'package:todo/cli/cli_session.dart';
import 'package:todo/mcp/board_client.dart';
import 'package:todo/mcp/task_service.dart';
import 'package:todo/mcp/todo_mcp_server.dart';
import 'package:todo/mcp/token_file.dart';
@ -17,6 +18,10 @@ import 'package:todo/mcp/token_file.dart';
import '../cli/fake_rtdb.dart';
class McpHarness {
/// [board] is the fake board `new_note` talks to; none by default.
McpHarness({this.board});
final BoardClient? board;
static const token = 'test-token-for-pc';
final tmp = Directory.systemTemp.createTempSync('mcp_harness');
@ -34,7 +39,7 @@ class McpHarness {
httpClient: db.client,
);
server = await TodoMcpServer.start(
tasks: TaskService(session),
tasks: TaskService(session, board: board),
tokens: tokens,
port: 0,
);

View File

@ -0,0 +1,128 @@
// add_task new_note over MCP: second note, guard merge, Start on the board.
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:mcp_dart/mcp_dart.dart';
import 'package:todo/mcp/board_client.dart';
import 'mcp_harness.dart';
const _repo = 'kuhyx/herosi';
void main() {
late McpHarness h;
late McpClient c;
var cards = <Map<String, dynamic>>[];
var up = true;
final posts = <String>[];
final board = BoardClient(
client: MockClient((req) async {
if (!up) throw http.ClientException('connection refused');
if (req.method == 'POST') {
posts.add(req.url.path);
return http.Response('{"ok":true}', 200);
}
if (req.url.path == '/api/board') {
return http.Response.bytes(
utf8.encode(jsonEncode({'tasks': cards})),
200,
);
}
return http.Response('<meta name="todoimpl-csrf" content="t">', 200);
}),
);
List<Map<String, dynamic>> cardsOf(String id, String column) => [
{
'id': '${id.substring(0, 8)}-0',
'title': 'Młody',
'column': column,
'actions': [
{'id': 'start', 'enabled': true},
],
},
];
setUp(() async {
cards = [];
up = true;
posts.clear();
h = McpHarness(board: board);
await h.start();
c = await h.connect();
});
tearDown(() => h.dispose());
Future<String> add(Map<String, dynamic> extra) => h.call(c, 'add_task', {
'repo': _repo,
'title': 'Młody',
'body': 'what',
'new_note': true,
...extra,
});
test('C1/C2: new_note opens a second note, a plain add does not', () async {
final shared = idIn(
await h.call(c, 'add_task', {'repo': _repo, 'title': 'Old'}),
);
final before = await h.call(c, 'get_task', {'id': shared});
final second = await add({});
expect(second, startsWith('added '));
expect(idIn(second), isNot(shared));
expect(await h.call(c, 'get_task', {'id': shared}), before);
final listed = await h.call(c, 'list_tasks', {'repo': _repo});
expect(listed.trim().split('\n'), hasLength(2));
final plain = await h.call(c, 'add_task', {'repo': _repo, 'title': 'C'});
expect(idIn(plain), shared);
expect(posts, isEmpty);
});
test('C3/C6: a duplicate merges and Start is POSTed', () async {
final x = idIn(await add({'done_check': 'true'}));
cards = cardsOf(x, 'backlog');
final r = await add({'done_check': 'true'});
expect(r, startsWith('merged into $x'));
expect(r, contains('start: ok ${x.substring(0, 8)}-0'));
expect(posts, ['/api/task/${x.substring(0, 8)}-0/start']);
final listed = await h.call(c, 'list_tasks', {'repo': _repo});
expect(listed.trim().split('\n'), hasLength(1));
});
test('C6: done_check none or low priority merges without Start', () async {
final x = idIn(await add({}));
cards = cardsOf(x, 'backlog');
final none = await add({});
expect(none, startsWith('merged into $x'));
expect(none, contains('start: skipped (done_check is none)'));
final low = await add({'done_check': 'true', 'body': 'low priority\nx'});
expect(low, contains('start: skipped (marked low priority)'));
expect(posts, isEmpty);
});
test('C5: a running target is left alone', () async {
final x = idIn(await add({'done_check': 'true'}));
final before = await h.call(c, 'get_task', {'id': x});
cards = cardsOf(x, 'running');
final r = await add({'done_check': 'true'});
expect(r, startsWith('added '));
expect(idIn(r), isNot(x));
expect(r, contains('not merged into ${x.substring(0, 8)}'));
expect(await h.call(c, 'get_task', {'id': x}), before);
});
test('C7: board down keeps the merge and says click Start', () async {
final x = idIn(await add({'done_check': 'true'}));
up = false;
final r = await add({'done_check': 'true'});
expect(r, startsWith('merged into $x'));
expect(r, contains('start: {ok: false, reason: "board unreachable'));
expect(r, contains('click Start on the board by hand'));
});
test('the add_task schema offers new_note', () async {
final tools = (await c.listTools()).tools;
final add = tools.singleWhere((t) => t.name == 'add_task');
expect(jsonEncode(add.inputSchema.toJson()), contains('new_note'));
});
}

View File

@ -0,0 +1,140 @@
// new_note: a second open note per repo, the duplicate guard that merges
// into it, and the shared add path that never joins such a note.
import 'package:flutter_test/flutter_test.dart';
import 'package:todo/data/note.dart';
import 'package:todo/data/note_repository.dart';
import 'package:todo/data/repo_note.dart';
import 'package:todo/data/repo_note_merge.dart';
import 'package:todo/data/repo_note_split.dart';
const _repo = 'kuhyx/herosi';
Note _n(String id, String text, {int day = 1, String repo = _repo}) => Note(
id: id,
text: text,
priority: Priority.medium,
status: Status.todo,
createdAt: DateTime.utc(2026, 10, day),
updatedAt: DateTime.utc(2026, 10, day),
repo: repo,
doneCheck: 'old',
);
Future<Note> _get(NoteRepository r, String id) async =>
(await r.listNotes()).singleWhere((n) => n.id == id);
void main() {
late NoteRepository repo;
setUp(() async => repo = await NoteRepository.openInMemory());
tearDown(() => repo.close());
Future<SplitAdd> add(String title, {Future<bool> Function(Note)? busy}) =>
addSeparateTask(
repo,
repo: _repo,
title: title,
body: 'what',
doneCheck: 'new',
priority: Priority.high,
by: 'claude',
busy: busy,
);
final shared = _n('aaaaaaaa-shared', 'herosi todos\n\n## Mędrzec\nlisted');
test('C1: builds a second open note, the shared one untouched', () async {
await repo.upsert(shared);
final r = await add('Mędrzec');
expect(r.kind, SplitKind.added);
expect(r.note.id, isNot(shared.id));
expect(r.note.text, 'herosi todos: Mędrzec\n\n## Mędrzec\nwhat');
expect(r.note.doneCheck, 'new');
expect(isSeparateNote(r.note), isTrue);
expect(isSeparateNote(shared), isFalse);
expect((await _get(repo, shared.id)).text, shared.text);
});
test('C2: a plain add joins the shared note, never a separate one', () async {
final split = _n('00000000-split', 'herosi todos: Old\n\n## Old\nx');
await repo.upsert(split);
await repo.upsert(_n('bbbbbbbb-shared', shared.text, day: 2));
final r = await addRepoTask(repo, repo: _repo, title: 'B');
expect(r.appended, isTrue);
expect(r.note.id, 'bbbbbbbb-shared');
await repo.upsert(
_n('bbbbbbbb-shared', 'x', day: 2).copyWith(status: Status.done),
);
final fresh = await addRepoTask(repo, repo: _repo, title: 'C');
expect(fresh.appended, isFalse);
expect(fresh.note.text, startsWith('herosi todos\n'));
});
test('C3: an exact title in another separate note merges into it', () async {
final x = _n('cccccccc-x', 'herosi todos: Młody\n\n## Młody\nfirst');
await repo.upsert(x);
final r = await add('Młody');
expect(r.kind, SplitKind.merged);
expect(r.note.id, x.id);
expect(r.note.text, '${x.text}\n\n## Młody\nwhat');
expect(r.note.doneCheck, '( old ) && ( new )');
expect(r.note.priority, Priority.high);
});
test('C4: shared-note, other-repo, done or near hits do not merge', () async {
await repo.upsert(shared);
await repo.upsert(
_n('dddddddd-o', 'x todos: Hekser\n\n## Hekser\n', repo: 'kuhyx/x'),
);
await repo.upsert(_n('eeeeeeee-d', 'herosi todos: H\n\n## [x] Hekser\n'));
await repo.upsert(_n('ffffffff-c', 'herosi todos: H\n\n## hekser \n'));
await repo.upsert(
_n('99999999-f', 'herosi todos: H\n\n```\n## Hekser\n```'),
);
expect((await add('Hekser')).kind, SplitKind.added);
expect((await add('Mędrzec')).kind, SplitKind.added);
});
test(
'C5: a running target is not edited; a separate note says why',
() async {
final x = _n('cccccccc-x', 'herosi todos: Młody\n\n## Młody\nfirst');
await repo.upsert(x);
final seen = <String>[];
final r = await add(
'Młody',
busy: (n) async {
seen.add(n.id);
return true;
},
);
expect(seen, [x.id]);
expect(r.kind, SplitKind.fellBack);
expect(r.note.id, isNot(x.id));
expect(r.why, contains('cccccccc'));
expect((await _get(repo, x.id)).text, x.text);
},
);
test('C10: the one-time merge plans nothing for separate notes', () {
final notes = [
_n('a', 'herosi todos\n\n## One\nx', day: 1),
_n('b', 'herosi todos: Two\n\n## Two\ny', day: 2),
_n('c', 'herosi todos: Three\n\n## Three\nz', day: 3),
];
expect(planRepoMerges(notes), isEmpty);
final legacy = planRepoMerges([...notes, _n('d', 'Legacy\n\nl', day: 4)]);
expect(legacy.single.kept.id, 'a');
expect(legacy.single.removed.map((n) => n.id), ['d']);
});
group('startBlock', () {
test('a real check and a normal body may start', () {
expect(startBlock('test -f x', 'what'), isNull);
});
test('C6: none or low priority never starts', () {
expect(startBlock(null, 'what'), 'done_check is none');
expect(startBlock(' None ', 'what'), 'done_check is none');
expect(startBlock('x', '\nLow priority\nrest'), 'marked low priority');
});
});
}