···115115 "-n",
116116 help="Nudge time: HH:MM, now, tomorrow HH:MM, or YYYYMMDDTHH:MM.",
117117 ),
118118+ force: bool = typer.Option(
119119+ False, "--force", help="Skip duplicate check and add anyway."
120120+ ),
118121) -> None:
119122 """Add a new todo item."""
120123 from datetime import datetime
···139142 except ValueError as exc:
140143 typer.echo(f"Error: {exc}", err=True)
141144 raise typer.Exit(1) from None
145145+146146+ # Cross-facet duplicate check
147147+ if not force:
148148+ matches = todo.find_cross_facet_matches(text, day, exclude_facet=facet)
149149+ if matches:
150150+ typer.echo(f"Duplicate detected for: {text}", err=True)
151151+ for match in matches:
152152+ typer.echo(
153153+ f" [{match['score']:.0f}%] {match['facet']}/{match['day']} "
154154+ f"line {match['line']}: {match['text']}",
155155+ err=True,
156156+ )
157157+ typer.echo("Use --force to add anyway.", err=True)
158158+ raise typer.Exit(1)
142159143160 try:
144161
+3-1
apps/todos/muse/daily.md
···2929SOL_DAY and SOL_FACET are set in your environment. Commands default to the current day and facet — only pass explicit values to override (e.g., checking yesterday's list).
30303131- `sol call todos list` – inspect the current numbered checklist
3232-- `sol call todos add TEXT` – append a new unchecked line (line number is auto-calculated)
3232+- `sol call todos add TEXT [--force]` – append a new unchecked line (line number is auto-calculated; --force skips cross-facet duplicate check)
3333- `sol call todos cancel LINE_NUMBER` – cancel a todo (soft delete); the entry remains but is hidden from view
3434- `sol call todos done LINE_NUMBER` – mark an entry complete
3535- `sol call todos upcoming -l LIMIT` – view upcoming todos
···56564. Check facet news for announced commitments: `sol call journal search "" -a news -d $day_YYYYMMDD -f FACET -n 5`
57575. Cancel duplicates or stale items via `sol call todos cancel`
58586. Add any high-value items missed by activity detection (e.g., cross-activity themes, carried commitments from follow-ups)
5959+7. If `sol call todos add` rejects an item as a cross-facet duplicate, review the match — skip if it's genuine, retry with `--force` only if the items are truly distinct
59606061Each candidate must be:
6162- **Actionable** – specific action with a clear outcome
···9495- Exceed 10 active items without explicit justification
9596- Invent work without journal evidence or historical context
9697- Re-add items that activity agents already captured
9898+- Use `--force` to bypass duplicate detection without verifying the match is a false positive
979998100## Interaction Protocol
99101
+9-1
apps/todos/muse/todo.md
···33333434### Todo Commands (SOL_DAY and SOL_FACET are set in your environment)
3535- `sol call todos list` – inspect the current numbered checklist
3636-- `sol call todos add TEXT` – append a new unchecked line
3636+- `sol call todos add TEXT [--force]` – append a new unchecked line (--force skips cross-facet duplicate check)
3737- `sol call todos done LINE_NUMBER` – mark an entry complete
3838- `sol call todos upcoming` – view upcoming todos to avoid duplicates
3939···8989- Pure speculation or hypothetical scenarios without concrete commitment
9090- Items that were both raised and resolved within this activity
9191- Duplicates of items already on the checklist or in upcoming todos
9292+9393+### Cross-Facet Dedup
9494+9595+The `sol call todos add` command automatically rejects items that fuzzy-match (≥70% similarity) an open todo in another facet within a ±1 day window. If the CLI rejects an add:
9696+9797+1. Check the reported match — if the existing item covers the same work, skip the add entirely
9898+2. If the new item is genuinely different despite the similarity, retry with `--force`
9999+3. Never use `--force` to create true duplicates across facets — one task, one facet
9210093101## Quality Guidelines
94102
+2-1
apps/todos/muse/todos/SKILL.md
···4848## add
49495050```bash
5151-sol call todos add TEXT [-d DAY] [-f FACET]
5151+sol call todos add TEXT [-d DAY] [-f FACET] [--force]
5252```
53535454Add a new todo item.
···6262- Line number is auto-calculated by the CLI; do not provide one.
6363- You can include time in the text as `(HH:MM)` suffix.
6464- Before adding a future todo, check `upcoming` first to avoid duplicates already scheduled on other days.
6565+- The command checks for fuzzy duplicates (≥70% similarity) across other facets within ±1 day. If a match is found, the add is rejected with a match report. Use `--force` to override.
65666667Examples:
6768
+57
apps/todos/tests/test_call.py
···385385 result = runner.invoke(call_app, ["todos", "done", "1"])
386386 assert result.exit_code == 0
387387 assert "[x]" in result.output
388388+389389+390390+class TestTodosAddDedup:
391391+ """Tests for cross-facet duplicate detection in 'sol call todos add'."""
392392+393393+ def test_add_rejects_duplicate_in_other_facet(self, move_env):
394394+ """Adding a duplicate todo in another facet is rejected with exit code 1."""
395395+ _, src_facet, dst_facet = move_env([{"text": "Draft Q1 plan"}], day="20240102")
396396+ result = runner.invoke(
397397+ call_app,
398398+ ["todos", "add", "Draft Q1 plan", "--day", "20240102", "--facet", dst_facet],
399399+ )
400400+ assert result.exit_code == 1
401401+ assert "Duplicate detected" in result.output
402402+403403+ def test_add_force_bypasses_dedup(self, move_env):
404404+ """--force flag allows adding despite duplicate detection."""
405405+ _, src_facet, dst_facet = move_env([{"text": "Draft Q1 plan"}], day="20240102")
406406+ result = runner.invoke(
407407+ call_app,
408408+ [
409409+ "todos",
410410+ "add",
411411+ "Draft Q1 plan",
412412+ "--day",
413413+ "20240102",
414414+ "--facet",
415415+ dst_facet,
416416+ "--force",
417417+ ],
418418+ )
419419+ assert result.exit_code == 0
420420+ assert "Draft Q1 plan" in result.output
421421+422422+ def test_add_succeeds_when_no_matches(self, move_env):
423423+ """Adding a unique todo succeeds normally."""
424424+ _, src_facet, dst_facet = move_env([{"text": "Buy groceries"}], day="20240102")
425425+ result = runner.invoke(
426426+ call_app,
427427+ ["todos", "add", "Draft Q1 plan", "--day", "20240102", "--facet", dst_facet],
428428+ )
429429+ assert result.exit_code == 0
430430+ assert "Draft Q1 plan" in result.output
431431+432432+ def test_add_dedup_stderr_format(self, move_env):
433433+ """Rejection message includes score, facet, day, line, and text."""
434434+ _, src_facet, dst_facet = move_env([{"text": "Draft Q1 plan"}], day="20240102")
435435+ result = runner.invoke(
436436+ call_app,
437437+ ["todos", "add", "Draft Q1 plan", "--day", "20240102", "--facet", dst_facet],
438438+ )
439439+ assert result.exit_code == 1
440440+ assert "100%" in result.output
441441+ assert src_facet in result.output
442442+ assert "20240102" in result.output
443443+ assert "line 1" in result.output
444444+ assert "--force" in result.output