corvee 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. corvee/__init__.py +11 -0
  2. corvee/__main__.py +10 -0
  3. corvee/actor.py +40 -0
  4. corvee/cli/__init__.py +9 -0
  5. corvee/cli/commands/__init__.py +5 -0
  6. corvee/cli/commands/brief.py +186 -0
  7. corvee/cli/commands/completion.py +47 -0
  8. corvee/cli/commands/doctor.py +141 -0
  9. corvee/cli/commands/explain.py +122 -0
  10. corvee/cli/commands/export.py +42 -0
  11. corvee/cli/commands/fact/__init__.py +45 -0
  12. corvee/cli/commands/fact/add.py +59 -0
  13. corvee/cli/commands/fact/delete.py +35 -0
  14. corvee/cli/commands/fact/list_.py +101 -0
  15. corvee/cli/commands/fact/retract.py +39 -0
  16. corvee/cli/commands/fact/revise.py +39 -0
  17. corvee/cli/commands/fact/search.py +77 -0
  18. corvee/cli/commands/fact/show.py +72 -0
  19. corvee/cli/commands/fact/unverify.py +39 -0
  20. corvee/cli/commands/fact/verify.py +41 -0
  21. corvee/cli/commands/import_.py +47 -0
  22. corvee/cli/commands/init.py +82 -0
  23. corvee/cli/commands/mcp/__init__.py +22 -0
  24. corvee/cli/commands/mcp/serve.py +108 -0
  25. corvee/cli/commands/task/__init__.py +57 -0
  26. corvee/cli/commands/task/add.py +226 -0
  27. corvee/cli/commands/task/assign.py +48 -0
  28. corvee/cli/commands/task/claim.py +45 -0
  29. corvee/cli/commands/task/claims.py +39 -0
  30. corvee/cli/commands/task/comment.py +37 -0
  31. corvee/cli/commands/task/label.py +59 -0
  32. corvee/cli/commands/task/labels.py +32 -0
  33. corvee/cli/commands/task/link.py +53 -0
  34. corvee/cli/commands/task/list_.py +181 -0
  35. corvee/cli/commands/task/mine.py +63 -0
  36. corvee/cli/commands/task/purge.py +39 -0
  37. corvee/cli/commands/task/ready.py +68 -0
  38. corvee/cli/commands/task/search.py +81 -0
  39. corvee/cli/commands/task/show.py +95 -0
  40. corvee/cli/commands/task/start.py +49 -0
  41. corvee/cli/commands/task/tree.py +88 -0
  42. corvee/cli/commands/task/unassign.py +38 -0
  43. corvee/cli/commands/task/unclaim.py +43 -0
  44. corvee/cli/commands/task/unlink.py +47 -0
  45. corvee/cli/commands/task/update.py +77 -0
  46. corvee/cli/completion.py +78 -0
  47. corvee/cli/context.py +105 -0
  48. corvee/cli/main.py +114 -0
  49. corvee/cli/params.py +52 -0
  50. corvee/cli/scope.py +155 -0
  51. corvee/config.py +206 -0
  52. corvee/constants.py +115 -0
  53. corvee/db/__init__.py +5 -0
  54. corvee/db/connection.py +103 -0
  55. corvee/db/events.py +143 -0
  56. corvee/db/export_import.py +89 -0
  57. corvee/db/facts.py +310 -0
  58. corvee/db/labels.py +93 -0
  59. corvee/db/like.py +12 -0
  60. corvee/db/links.py +182 -0
  61. corvee/db/schema.py +132 -0
  62. corvee/db/stats.py +192 -0
  63. corvee/db/tasks.py +762 -0
  64. corvee/errors.py +58 -0
  65. corvee/guards/__init__.py +5 -0
  66. corvee/guards/ancestry.py +38 -0
  67. corvee/guards/fields.py +31 -0
  68. corvee/guards/labels.py +23 -0
  69. corvee/guards/parent_child.py +31 -0
  70. corvee/guards/scope.py +26 -0
  71. corvee/guards/transitions.py +27 -0
  72. corvee/mcp/__init__.py +5 -0
  73. corvee/mcp/dispatch.py +117 -0
  74. corvee/mcp/scope.py +81 -0
  75. corvee/mcp/server.py +160 -0
  76. corvee/mcp/tools_common.py +112 -0
  77. corvee/mcp/tools_fact.py +136 -0
  78. corvee/mcp/tools_read.py +228 -0
  79. corvee/mcp/tools_write.py +323 -0
  80. corvee/mcp/worker.py +50 -0
  81. corvee/models.py +224 -0
  82. corvee/output.py +227 -0
  83. corvee/references.py +81 -0
  84. corvee/timeutil.py +44 -0
  85. corvee-0.1.0.dist-info/METADATA +107 -0
  86. corvee-0.1.0.dist-info/RECORD +89 -0
  87. corvee-0.1.0.dist-info/WHEEL +4 -0
  88. corvee-0.1.0.dist-info/entry_points.txt +2 -0
  89. corvee-0.1.0.dist-info/licenses/LICENSE +201 -0
corvee/__init__.py ADDED
@@ -0,0 +1,11 @@
1
+ #
2
+ # Copyright (C) 2026 Benjamin Thomas Schwertfeger
3
+ # SPDX-License-Identifier: Apache-2.0
4
+ # https://github.com/btschwertfeger
5
+ #
6
+
7
+ from importlib.metadata import version
8
+
9
+ __version__ = version("corvee")
10
+
11
+ __all__ = ["__version__"]
corvee/__main__.py ADDED
@@ -0,0 +1,10 @@
1
+ #
2
+ # Copyright (C) 2026 Benjamin Thomas Schwertfeger
3
+ # SPDX-License-Identifier: Apache-2.0
4
+ # https://github.com/btschwertfeger
5
+ #
6
+
7
+ from corvee.cli.main import main
8
+
9
+ if __name__ == "__main__":
10
+ main()
corvee/actor.py ADDED
@@ -0,0 +1,40 @@
1
+ #
2
+ # Copyright (C) 2026 Benjamin Thomas Schwertfeger
3
+ # SPDX-License-Identifier: Apache-2.0
4
+ # https://github.com/btschwertfeger
5
+ #
6
+
7
+ import getpass
8
+ import os
9
+
10
+ import click
11
+
12
+
13
+ def _flag_value(name: str) -> str | None:
14
+ """The global --actor/--session-id value from the running CLI invocation,
15
+ if any. None outside a click context (e.g. calling these functions
16
+ directly in a test) or when the flag was not passed.
17
+ """
18
+ ctx = click.get_current_context(silent=True)
19
+ if ctx is None:
20
+ return None
21
+ return ctx.find_root().params.get(name)
22
+
23
+
24
+ def resolve_actor(override: str | None = None) -> str:
25
+ """--actor if given (explicitly, or via the running CLI's --actor flag),
26
+ else CORVEE_ACTOR if set, else human:$USER.
27
+ """
28
+ actor = override or _flag_value("actor") or os.environ.get("CORVEE_ACTOR")
29
+ if actor:
30
+ return actor
31
+ user = os.environ.get("USER") or getpass.getuser()
32
+ return f"human:{user}"
33
+
34
+
35
+ def resolve_session_id(override: str | None = None) -> str | None:
36
+ """--session-id if given (explicitly, or via the running CLI's
37
+ --session-id flag), else CORVEE_SESSION_ID if set and non-empty, else
38
+ None.
39
+ """
40
+ return override or _flag_value("session_id") or os.environ.get("CORVEE_SESSION_ID") or None
corvee/cli/__init__.py ADDED
@@ -0,0 +1,9 @@
1
+ #
2
+ # Copyright (C) 2026 Benjamin Thomas Schwertfeger
3
+ # SPDX-License-Identifier: Apache-2.0
4
+ # https://github.com/btschwertfeger
5
+ #
6
+
7
+ from corvee.cli.main import cli
8
+
9
+ __all__ = ["cli"]
@@ -0,0 +1,5 @@
1
+ #
2
+ # Copyright (C) 2026 Benjamin Thomas Schwertfeger
3
+ # SPDX-License-Identifier: Apache-2.0
4
+ # https://github.com/btschwertfeger
5
+ #
@@ -0,0 +1,186 @@
1
+ #
2
+ # Copyright (C) 2026 Benjamin Thomas Schwertfeger
3
+ # SPDX-License-Identifier: Apache-2.0
4
+ # https://github.com/btschwertfeger
5
+ #
6
+
7
+ import json
8
+ import sqlite3
9
+ from datetime import UTC, datetime
10
+ from pathlib import Path
11
+ from typing import Any, cast
12
+
13
+ import click
14
+
15
+ from corvee.actor import resolve_actor
16
+ from corvee.cli.context import corvee_context
17
+ from corvee.cli.scope import fetch_merged, sort_tasks
18
+ from corvee.config import project_exists
19
+ from corvee.constants import (
20
+ DEFAULT_STALE_DURATION,
21
+ LIST_TABLE_DEFAULT_FIELDS,
22
+ SCOPE_FILTERS,
23
+ Scope,
24
+ ScopeFilter,
25
+ )
26
+ from corvee.db.events import get_last_comment
27
+ from corvee.db.labels import list_labels_with_counts
28
+ from corvee.db.tasks import TaskFilter, TaskRow, list_tasks, mine_tasks, ready_tasks
29
+ from corvee.output import render_table
30
+ from corvee.timeutil import parse_duration, timestamp
31
+
32
+ READY_LIMIT = 5
33
+
34
+ EPILOG = """\
35
+ \b
36
+ Examples:
37
+ Reorient at the start of a session in one call:
38
+ corvee brief
39
+ Get the same snapshot as machine-readable output:
40
+ corvee brief --json
41
+ See the machine-wide backlog alongside this project's:
42
+ corvee brief --scope all --json
43
+ Check only what's filed in the shared global database:
44
+ corvee brief --scope global --json
45
+ """
46
+
47
+
48
+ def mine_section(
49
+ scope_filter: ScopeFilter,
50
+ *,
51
+ actor: str | None = None,
52
+ project_db_path: Path | None = None,
53
+ local_available: bool | None = None,
54
+ ) -> list[dict[str, Any]]:
55
+ """Open tasks claimed by (or assigned and unclaimed to) `actor`, each
56
+ with its last comment. `actor`, omitted, resolves ambiently
57
+ (`resolve_actor()`, the CLI's own `$CORVEE_ACTOR`/`--actor`); the MCP
58
+ surface passes its already-resolved `ServerConfig.actor` instead
59
+ (spec §10.1). `project_db_path`/`local_available`, given, thread
60
+ through to `fetch_merged` the same way -- `project_db_path` is the MCP
61
+ surface's own already-resolved project db path, used verbatim instead
62
+ of a fresh `resolve_project` per call (TASK-37).
63
+ """
64
+ resolved_actor = actor or resolve_actor()
65
+
66
+ def fetch(conn: sqlite3.Connection, s: Scope) -> list[tuple[TaskRow, dict[str, Any] | None]]:
67
+ tasks = mine_tasks(conn, resolved_actor, scope=s)
68
+ return [(task, get_last_comment(conn, task.id)) for task in tasks]
69
+
70
+ pairs = fetch_merged(
71
+ scope_filter,
72
+ fetch,
73
+ actor=resolved_actor,
74
+ project_db_path=project_db_path,
75
+ local_available=local_available,
76
+ )
77
+ pairs.sort(key=lambda p: p[0].id, reverse=True)
78
+ pairs.sort(key=lambda p: p[0].claimed_at or "", reverse=True)
79
+ results = []
80
+ for task, last_comment in pairs:
81
+ detail = task.to_dict()
82
+ detail["last_comment"] = last_comment
83
+ results.append(detail)
84
+ return results
85
+
86
+
87
+ def ready_section(
88
+ scope_filter: ScopeFilter,
89
+ *,
90
+ actor: str | None = None,
91
+ project_db_path: Path | None = None,
92
+ local_available: bool | None = None,
93
+ ) -> list[dict[str, Any]]:
94
+ """Up to `READY_LIMIT` unclaimed, open, unblocked tasks. See
95
+ `mine_section` for the `actor`/`project_db_path`/`local_available`
96
+ parameters.
97
+ """
98
+ tasks = sort_tasks(
99
+ fetch_merged(
100
+ scope_filter,
101
+ lambda conn, s: ready_tasks(conn, scope=s),
102
+ actor=actor,
103
+ project_db_path=project_db_path,
104
+ local_available=local_available,
105
+ )
106
+ )
107
+ return [t.to_dict() for t in tasks[:READY_LIMIT]]
108
+
109
+
110
+ def stale_section(
111
+ scope_filter: ScopeFilter,
112
+ *,
113
+ actor: str | None = None,
114
+ project_db_path: Path | None = None,
115
+ local_available: bool | None = None,
116
+ ) -> list[dict[str, Any]]:
117
+ """Tasks whose claim has gone stale. See `mine_section` for the
118
+ `actor`/`project_db_path`/`local_available` parameters.
119
+ """
120
+ cutoff = timestamp(datetime.now(UTC) - parse_duration(DEFAULT_STALE_DURATION))
121
+ filt = TaskFilter(stale_before=cutoff)
122
+ tasks = sort_tasks(
123
+ fetch_merged(
124
+ scope_filter,
125
+ lambda conn, s: list_tasks(conn, filt, scope=s),
126
+ actor=actor,
127
+ project_db_path=project_db_path,
128
+ local_available=local_available,
129
+ )
130
+ )
131
+ return [t.to_dict() for t in tasks]
132
+
133
+
134
+ def labels_section(
135
+ *,
136
+ actor: str | None = None,
137
+ project_db_path: Path | None = None,
138
+ has_project: bool | None = None,
139
+ ) -> list[dict[str, Any]]:
140
+ """The local project's label vocabulary — never merged across scope,
141
+ the same restriction `task labels` already has (§3.3).
142
+
143
+ Empty, not an error, when no local project resolves — `brief`'s other
144
+ three sections already degrade the same way for a purely-global call
145
+ (§3.3), and `labels` is an auxiliary section of that same call, not a
146
+ separate ask for local data the way standalone `task labels` is.
147
+ `has_project`, given, is used verbatim instead of the ambient
148
+ `project_exists()` check, the same override `mine_section` and its
149
+ siblings take as `local_available`. `project_db_path`, given, is
150
+ `corvee_context`'s own override, same as its sibling sections.
151
+ """
152
+ available = project_exists() if has_project is None else has_project
153
+ if not available:
154
+ return []
155
+ with corvee_context(write=False, actor=actor, project_db_path=project_db_path) as ctx:
156
+ rows = list_labels_with_counts(ctx.conn)
157
+ return [{"name": name, "task_count": count} for name, count in rows]
158
+
159
+
160
+ @click.command(epilog=EPILOG)
161
+ @click.option("--scope", "-s", "scope_filter", type=click.Choice(SCOPE_FILTERS), default="all")
162
+ @click.option("--json", "-j", "as_json", is_flag=True)
163
+ def brief(scope_filter: str, as_json: bool) -> None:
164
+ """Session-start snapshot: what's claimed, what's ready, what's gone stale.
165
+
166
+ Combines `task mine`, `task ready` and `task list --stale` into one
167
+ read-only call.
168
+ """
169
+ scope = cast(ScopeFilter, scope_filter)
170
+ sections = {
171
+ "mine": mine_section(scope),
172
+ "ready": ready_section(scope),
173
+ "stale": stale_section(scope),
174
+ "labels": labels_section(),
175
+ }
176
+ if as_json:
177
+ click.echo(json.dumps(sections))
178
+ return
179
+ blocks = [
180
+ f"{name}:\n{render_table(rows, default_fields=LIST_TABLE_DEFAULT_FIELDS)}"
181
+ for name, rows in sections.items()
182
+ if name != "labels" and rows
183
+ ]
184
+ if sections["labels"]:
185
+ blocks.append(f"labels:\n{render_table(sections['labels'], fields=('name', 'task_count'))}")
186
+ click.echo("\n\n".join(blocks))
@@ -0,0 +1,47 @@
1
+ #
2
+ # Copyright (C) 2026 Benjamin Thomas Schwertfeger
3
+ # SPDX-License-Identifier: Apache-2.0
4
+ # https://github.com/btschwertfeger
5
+ #
6
+
7
+ import click
8
+ import click.shell_completion
9
+
10
+ _COMPLETE_VAR = "_CORVEE_COMPLETE"
11
+ _SHELLS = ("bash", "zsh", "fish")
12
+
13
+ EPILOG = """\
14
+ \b
15
+ Examples:
16
+ Print the bash activation script, to inspect before trusting it:
17
+ corvee completion bash
18
+ Print the zsh script instead:
19
+ corvee completion zsh
20
+ Print the fish script instead:
21
+ corvee completion fish
22
+
23
+ Enable it by `eval`ing the output, e.g. once per shell session:
24
+ eval "$(corvee completion bash)"
25
+ Or permanently, by adding the same line to your shell's rc file:
26
+ echo 'eval "$(corvee completion bash)"' >> ~/.bashrc
27
+ """
28
+
29
+
30
+ @click.command(epilog=EPILOG)
31
+ @click.argument("shell", type=click.Choice(_SHELLS))
32
+ def completion(shell: str) -> None:
33
+ """Print a shell completion script; `eval` its output to enable it.
34
+
35
+ Covers subcommand and flag names, click.Choice values (--state,
36
+ --priority, ...), and TASK-<n>/FACT-<n> arguments completed against
37
+ real ids in the current project.
38
+ """
39
+ # Deferred import: corvee.cli.main imports this module to register the
40
+ # command, so importing it back at module load time would be circular.
41
+ from corvee.cli.main import cli as root_cli
42
+
43
+ comp_cls = click.shell_completion.get_completion_class(shell)
44
+ if comp_cls is None:
45
+ # Unreachable: click.Choice(_SHELLS) already restricts to known shells.
46
+ raise AssertionError(f"no completion support for shell {shell!r}")
47
+ click.echo(comp_cls(root_cli, {}, "corvee", _COMPLETE_VAR).source())
@@ -0,0 +1,141 @@
1
+ #
2
+ # Copyright (C) 2026 Benjamin Thomas Schwertfeger
3
+ # SPDX-License-Identifier: Apache-2.0
4
+ # https://github.com/btschwertfeger
5
+ #
6
+
7
+ import json
8
+ from datetime import UTC, datetime
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ import click
13
+
14
+ from corvee.cli.context import corvee_context
15
+ from corvee.config import global_db_path, project_exists
16
+ from corvee.constants import DEFAULT_STALE_DURATION, Scope
17
+ from corvee.db.connection import open_connection
18
+ from corvee.db.schema import CURRENT_SCHEMA_VERSION
19
+ from corvee.db.stats import integrity_findings, project_stats
20
+ from corvee.timeutil import parse_duration, timestamp
21
+
22
+ EPILOG = """\
23
+ \b
24
+ Examples:
25
+ Check the project's overall health at a glance:
26
+ corvee doctor
27
+ Get the same report as machine-readable output:
28
+ corvee doctor --json
29
+ Flag claims idle for over an hour as stale:
30
+ corvee doctor --stale 1h --json
31
+ """
32
+
33
+
34
+ def _stats_lines(stats: dict[str, Any], *, prefix: str = "") -> list[str]:
35
+ return [
36
+ f"{prefix}tasks.total: {stats['tasks']['total']}",
37
+ *(
38
+ f"{prefix}tasks.by_state.{state}: {count}"
39
+ for state, count in stats["tasks"]["by_state"].items()
40
+ ),
41
+ f"{prefix}tasks.claimed: {stats['tasks']['claimed']}",
42
+ f"{prefix}tasks.stale: {stats['tasks']['stale']}",
43
+ f"{prefix}facts.total: {stats['facts']['total']}",
44
+ *(
45
+ f"{prefix}facts.by_status.{status}: {count}"
46
+ for status, count in stats["facts"]["by_status"].items()
47
+ ),
48
+ f"{prefix}labels: {stats['labels']}",
49
+ ]
50
+
51
+
52
+ def _findings_lines(findings: list[dict[str, Any]], *, prefix: str = "") -> list[str]:
53
+ if not findings:
54
+ return [f"{prefix}findings: none"]
55
+ lines = [f"{prefix}findings:"]
56
+ for finding in findings:
57
+ if finding["kind"] == "cycle":
58
+ lines.append(f" {finding['relation']} cycle: {' -> '.join(finding['task_ids'])}")
59
+ else:
60
+ lines.append(
61
+ f" dangling foreign key: {finding['table']}.rowid={finding['rowid']}"
62
+ f" -> {finding['references']}"
63
+ )
64
+ return lines
65
+
66
+
67
+ def _render_text(payload: dict[str, Any]) -> str:
68
+ lines = [f"schema_version: {payload['schema_version']}"]
69
+
70
+ local_payload = payload["local"]
71
+ if local_payload is None:
72
+ lines.append("local: not initialized in this directory (no .corvee/config.toml found)")
73
+ else:
74
+ lines.append(f"local.db_path: {local_payload['db_path']}")
75
+ lines.extend(_stats_lines(local_payload, prefix="local."))
76
+ lines.extend(_findings_lines(local_payload["findings"], prefix="local."))
77
+
78
+ global_payload = payload["global"]
79
+ if global_payload is None:
80
+ lines.append("global: not created yet (no --global task or fact filed)")
81
+ else:
82
+ lines.append(f"global.db_path: {global_payload['db_path']}")
83
+ lines.extend(_stats_lines(global_payload, prefix="global."))
84
+ lines.extend(_findings_lines(global_payload["findings"], prefix="global."))
85
+ return "\n".join(lines)
86
+
87
+
88
+ def _db_stats(path: Path, *, stale_before: str, scope: Scope) -> dict[str, Any]:
89
+ conn = open_connection(path)
90
+ try:
91
+ conn.execute("BEGIN")
92
+ stats = project_stats(conn, stale_before=stale_before)
93
+ findings = integrity_findings(conn, scope=scope, stale_before=stale_before)
94
+ conn.execute("COMMIT")
95
+ finally:
96
+ conn.close()
97
+ return {"db_path": str(path), **stats, "findings": findings}
98
+
99
+
100
+ def _local_stats(*, stale_before: str) -> dict[str, Any] | None:
101
+ """The local project's own stats, or None if no project resolves from cwd.
102
+
103
+ Checking existence rather than always resolving is what lets `doctor`
104
+ run from any directory (§3.3, mirroring the global side below) instead
105
+ of hard-failing outside a project.
106
+ """
107
+ if not project_exists():
108
+ return None
109
+ with corvee_context(write=False) as ctx:
110
+ return _db_stats(ctx.db_path, stale_before=stale_before, scope="local")
111
+
112
+
113
+ def _global_stats(*, stale_before: str) -> dict[str, Any] | None:
114
+ """The global database's own stats, or None if it does not exist yet.
115
+
116
+ Checking existence rather than always opening it is what keeps `doctor`
117
+ read-only for a project that has never used `--global`: nothing about
118
+ running this command creates the file.
119
+ """
120
+ path = global_db_path()
121
+ if not path.is_file():
122
+ return None
123
+ return _db_stats(path, stale_before=stale_before, scope="global")
124
+
125
+
126
+ @click.command(epilog=EPILOG)
127
+ @click.option("--stale", "-i", "stale_duration", default=DEFAULT_STALE_DURATION, show_default=True)
128
+ @click.option("--json", "-j", "as_json", is_flag=True)
129
+ def doctor(stale_duration: str, as_json: bool) -> None:
130
+ """Project health: task/fact counts, staleness, and schema version."""
131
+ cutoff = datetime.now(UTC) - parse_duration(stale_duration)
132
+ stale_before = timestamp(cutoff)
133
+ payload = {
134
+ "schema_version": CURRENT_SCHEMA_VERSION,
135
+ "local": _local_stats(stale_before=stale_before),
136
+ "global": _global_stats(stale_before=stale_before),
137
+ }
138
+ if as_json:
139
+ click.echo(json.dumps(payload))
140
+ else:
141
+ click.echo(_render_text(payload))
@@ -0,0 +1,122 @@
1
+ #
2
+ # Copyright (C) 2026 Benjamin Thomas Schwertfeger
3
+ # SPDX-License-Identifier: Apache-2.0
4
+ # https://github.com/btschwertfeger
5
+ #
6
+
7
+ import click
8
+
9
+ EPILOG = """\
10
+ \b
11
+ Examples:
12
+ Print the cheat sheet:
13
+ corvee explain
14
+ Page through it if the terminal is too short:
15
+ corvee explain | less
16
+ Save it as a file an agent can be pointed at directly:
17
+ corvee explain > AGENTS_corvee_cheatsheet.txt
18
+ """
19
+
20
+ EXPLAIN_TEXT = """\
21
+ corvee: a local, multi-agent-aware CLI issue tracker, plus a standalone
22
+ store of checked-true facts. Multiple agents and humans can work the same
23
+ backlog concurrently — claims prevent two actors from editing the same task
24
+ at once.
25
+
26
+ Operating rule, not an example to adapt: track work as you go, never after
27
+ the fact. This applies to any nontrivial piece of work, not just code — a
28
+ research task, a writeup, a document review, anything worth resuming.
29
+ Before starting, `task add` it (with --description) and `task claim` it —
30
+ filing a task once the work is already done gives a resuming session
31
+ nothing to resume from, which is the one thing this tool exists to
32
+ prevent. The same applies to facts: `fact add`/`fact verify` a claim the
33
+ moment you establish it (from research, a source, a computed result — not
34
+ only code), not batched in at the end of a session.
35
+
36
+ TASKS
37
+
38
+ One task per independently-resolvable item, not one umbrella task: a
39
+ batch of distinct, separately-finishable pieces (a batch of FIXMEs, a
40
+ list of bugs) gets one `task add` each. Bundling them loses corvee's
41
+ resumability and independent-completion value — no way to tell which
42
+ piece is actually done, no way to split the batch across agents.
43
+
44
+ Resuming beats starting something new:
45
+ corvee brief --json # mine + ready + stale in one call
46
+ corvee task mine --json # what am I already working on?
47
+
48
+ Check before you file, and see what's startable right now:
49
+ corvee task ready --json # unclaimed, unblocked, open tasks
50
+ corvee task search "auth token" --json # does a task for this exist already?
51
+ corvee task list --fields id,title --json # cheap session-start overview
52
+
53
+ File it, claim it, then work it — in that order, every time. Don't skip
54
+ steps: stay in_progress while working, comment before marking done — a
55
+ task with no claim and no comment leaves nothing for a resuming session
56
+ (yours or another agent's) to pick up from.
57
+ corvee task add "Fix the flaky auth test" --description "..." --json
58
+ corvee task claim TASK-14 --json
59
+ corvee task update TASK-14 --state in_progress --json
60
+ corvee task comment TASK-14 "found the root cause, writing a fix" --json
61
+ corvee task update TASK-14 --state done --json
62
+ corvee task show TASK-14 --json # full detail, incl. the timeline
63
+
64
+ Don't leave every task at the --type/--priority defaults (task/medium)
65
+ when a more specific one is true: a bug found along the way is
66
+ --type bug, something genuinely blocking is --priority high or
67
+ critical, and a task that can't start until another finishes is a
68
+ relation, not just a sentence in the description:
69
+ corvee task link TASK-15 TASK-14 --relation blocks --json
70
+
71
+ Claims: `corvee task claim <id>` fails if another actor holds the task,
72
+ unless --force. Re-running `corvee task claim <id>` on a task already held
73
+ refreshes it — the heartbeat. A claim goes stale after 4h of silence and can
74
+ then be taken by another agent (see `corvee task list --stale`). See who
75
+ currently holds what, and since when:
76
+ corvee task claims --json
77
+
78
+ Routing work to a specific actor without claiming it on their behalf:
79
+ corvee task assign TASK-14 --to agent:claude --json
80
+ `task mine` then shows it for agent:claude the moment it's unclaimed.
81
+
82
+ FACTS
83
+
84
+ Facts are a separate, standalone store for checked-true claims with proof —
85
+ not tasks, and not linked to them by the schema. Connect the two by
86
+ convention if useful, e.g. mentioning a fact id in a task comment:
87
+ corvee fact add "requests is Apache-2.0 licensed" --json --proof \\
88
+ "pip download requests --no-deps -d /tmp && unzip -q /tmp/requests-*.whl -d /tmp/requests-whl \\
89
+ && grep -i '^License:' /tmp/requests-whl/*/METADATA"
90
+ corvee fact search "license" --json # does a fact for this exist already?
91
+
92
+ GENERAL
93
+
94
+ Not project-specific? --global on task/fact add files into one database
95
+ shared across every project on the machine instead of this one. Every
96
+ other command reads local vs. global from the id itself (TASK-GLOBAL-<n>),
97
+ so --global is only ever needed on add:
98
+ corvee task add "renew the CA cert" --description "expires yearly" --global --json
99
+ corvee task list --scope local --json # this project's backlog only, not merged
100
+
101
+ Identity: corvee --actor agent:claude --session-id <token> <command> on
102
+ every call — passing the flags keeps the command line a plain `corvee ...`
103
+ invocation a permission allowlist can match, instead of an `export`
104
+ beforehand, which usually needs broader permissions. No --actor given
105
+ defaults to human:$USER. Set it explicitly whenever more than one session
106
+ might touch this backlog at once: two unconfigured sessions on the same
107
+ machine share that identity, and claims stop protecting anything between
108
+ them. corvee doctor flags a claimed task with recent activity from more
109
+ than one session under one actor string, which is what that collision
110
+ looks like after the fact.
111
+
112
+ Always pass --json. Success is a JSON array of objects on stdout; failure is
113
+ an object on stderr: {"error": {"code": ..., "message": ...}}.
114
+
115
+ Full flag reference for any command: corvee <command> --help
116
+ """
117
+
118
+
119
+ @click.command(epilog=EPILOG)
120
+ def explain() -> None:
121
+ """Print a compact, agent-oriented cheat sheet."""
122
+ click.echo(EXPLAIN_TEXT)
@@ -0,0 +1,42 @@
1
+ #
2
+ # Copyright (C) 2026 Benjamin Thomas Schwertfeger
3
+ # SPDX-License-Identifier: Apache-2.0
4
+ # https://github.com/btschwertfeger
5
+ #
6
+
7
+ import json
8
+ from pathlib import Path
9
+ from typing import cast
10
+
11
+ import click
12
+
13
+ from corvee.cli.context import corvee_context
14
+ from corvee.constants import SCOPES, Scope
15
+ from corvee.db.export_import import export_project
16
+
17
+ EPILOG = """\
18
+ \b
19
+ Examples:
20
+ Dump the whole project to stdout:
21
+ corvee export
22
+ Write the dump to a file instead:
23
+ corvee export --output backup.json
24
+ Name the backup file after today's date:
25
+ corvee export --output "$(date +%F)-corvee-backup.json"
26
+ Back up the machine-wide global database instead:
27
+ corvee export --scope global --output global-backup.json
28
+ """
29
+
30
+
31
+ @click.command(epilog=EPILOG)
32
+ @click.option("--output", "-o", "output_path", type=click.Path())
33
+ @click.option("--scope", "-s", "scope", type=click.Choice(SCOPES), default="local")
34
+ def export(output_path: str | None, scope: str) -> None:
35
+ """Dump the whole project (or --scope global) as JSON."""
36
+ with corvee_context(write=False, scope=cast(Scope, scope)) as ctx:
37
+ data = export_project(ctx.conn)
38
+ text = json.dumps(data)
39
+ if output_path:
40
+ Path(output_path).write_text(text)
41
+ else:
42
+ click.echo(text)
@@ -0,0 +1,45 @@
1
+ #
2
+ # Copyright (C) 2026 Benjamin Thomas Schwertfeger
3
+ # SPDX-License-Identifier: Apache-2.0
4
+ # https://github.com/btschwertfeger
5
+ #
6
+
7
+ import click
8
+
9
+ from corvee.cli.commands.fact.add import add
10
+ from corvee.cli.commands.fact.delete import delete
11
+ from corvee.cli.commands.fact.list_ import list_command
12
+ from corvee.cli.commands.fact.retract import retract
13
+ from corvee.cli.commands.fact.revise import revise
14
+ from corvee.cli.commands.fact.search import search
15
+ from corvee.cli.commands.fact.show import show
16
+ from corvee.cli.commands.fact.unverify import unverify
17
+ from corvee.cli.commands.fact.verify import verify
18
+ from corvee.cli.params import AliasGroup
19
+
20
+
21
+ @click.group(name="fact", cls=AliasGroup, hidden_aliases={"ls"})
22
+ def fact_group() -> None:
23
+ """Standalone, checked-true claims, kept separate from tasks.
24
+
25
+ \b
26
+ Lifecycle: a fact starts unverified. `verify` marks it verified with
27
+ proof and a timestamp; `unverify` moves it back. `revise` changes the
28
+ claim text and, if it was verified, resets it to unverified, since the
29
+ old proof said nothing about the new text. `retract` withdraws a fact
30
+ that should not have existed, dropping it out of the default `list`;
31
+ `verify`, `unverify`, and `revise` all still work on a retracted fact
32
+ and bring it back into the normal flow.
33
+ """
34
+
35
+
36
+ fact_group.add_command(add)
37
+ fact_group.add_command(list_command)
38
+ fact_group.add_command(list_command, name="ls")
39
+ fact_group.add_command(search)
40
+ fact_group.add_command(show)
41
+ fact_group.add_command(verify)
42
+ fact_group.add_command(unverify)
43
+ fact_group.add_command(revise)
44
+ fact_group.add_command(retract)
45
+ fact_group.add_command(delete)