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.
- corvee/__init__.py +11 -0
- corvee/__main__.py +10 -0
- corvee/actor.py +40 -0
- corvee/cli/__init__.py +9 -0
- corvee/cli/commands/__init__.py +5 -0
- corvee/cli/commands/brief.py +186 -0
- corvee/cli/commands/completion.py +47 -0
- corvee/cli/commands/doctor.py +141 -0
- corvee/cli/commands/explain.py +122 -0
- corvee/cli/commands/export.py +42 -0
- corvee/cli/commands/fact/__init__.py +45 -0
- corvee/cli/commands/fact/add.py +59 -0
- corvee/cli/commands/fact/delete.py +35 -0
- corvee/cli/commands/fact/list_.py +101 -0
- corvee/cli/commands/fact/retract.py +39 -0
- corvee/cli/commands/fact/revise.py +39 -0
- corvee/cli/commands/fact/search.py +77 -0
- corvee/cli/commands/fact/show.py +72 -0
- corvee/cli/commands/fact/unverify.py +39 -0
- corvee/cli/commands/fact/verify.py +41 -0
- corvee/cli/commands/import_.py +47 -0
- corvee/cli/commands/init.py +82 -0
- corvee/cli/commands/mcp/__init__.py +22 -0
- corvee/cli/commands/mcp/serve.py +108 -0
- corvee/cli/commands/task/__init__.py +57 -0
- corvee/cli/commands/task/add.py +226 -0
- corvee/cli/commands/task/assign.py +48 -0
- corvee/cli/commands/task/claim.py +45 -0
- corvee/cli/commands/task/claims.py +39 -0
- corvee/cli/commands/task/comment.py +37 -0
- corvee/cli/commands/task/label.py +59 -0
- corvee/cli/commands/task/labels.py +32 -0
- corvee/cli/commands/task/link.py +53 -0
- corvee/cli/commands/task/list_.py +181 -0
- corvee/cli/commands/task/mine.py +63 -0
- corvee/cli/commands/task/purge.py +39 -0
- corvee/cli/commands/task/ready.py +68 -0
- corvee/cli/commands/task/search.py +81 -0
- corvee/cli/commands/task/show.py +95 -0
- corvee/cli/commands/task/start.py +49 -0
- corvee/cli/commands/task/tree.py +88 -0
- corvee/cli/commands/task/unassign.py +38 -0
- corvee/cli/commands/task/unclaim.py +43 -0
- corvee/cli/commands/task/unlink.py +47 -0
- corvee/cli/commands/task/update.py +77 -0
- corvee/cli/completion.py +78 -0
- corvee/cli/context.py +105 -0
- corvee/cli/main.py +114 -0
- corvee/cli/params.py +52 -0
- corvee/cli/scope.py +155 -0
- corvee/config.py +206 -0
- corvee/constants.py +115 -0
- corvee/db/__init__.py +5 -0
- corvee/db/connection.py +103 -0
- corvee/db/events.py +143 -0
- corvee/db/export_import.py +89 -0
- corvee/db/facts.py +310 -0
- corvee/db/labels.py +93 -0
- corvee/db/like.py +12 -0
- corvee/db/links.py +182 -0
- corvee/db/schema.py +132 -0
- corvee/db/stats.py +192 -0
- corvee/db/tasks.py +762 -0
- corvee/errors.py +58 -0
- corvee/guards/__init__.py +5 -0
- corvee/guards/ancestry.py +38 -0
- corvee/guards/fields.py +31 -0
- corvee/guards/labels.py +23 -0
- corvee/guards/parent_child.py +31 -0
- corvee/guards/scope.py +26 -0
- corvee/guards/transitions.py +27 -0
- corvee/mcp/__init__.py +5 -0
- corvee/mcp/dispatch.py +117 -0
- corvee/mcp/scope.py +81 -0
- corvee/mcp/server.py +160 -0
- corvee/mcp/tools_common.py +112 -0
- corvee/mcp/tools_fact.py +136 -0
- corvee/mcp/tools_read.py +228 -0
- corvee/mcp/tools_write.py +323 -0
- corvee/mcp/worker.py +50 -0
- corvee/models.py +224 -0
- corvee/output.py +227 -0
- corvee/references.py +81 -0
- corvee/timeutil.py +44 -0
- corvee-0.1.0.dist-info/METADATA +107 -0
- corvee-0.1.0.dist-info/RECORD +89 -0
- corvee-0.1.0.dist-info/WHEEL +4 -0
- corvee-0.1.0.dist-info/entry_points.txt +2 -0
- corvee-0.1.0.dist-info/licenses/LICENSE +201 -0
corvee/__init__.py
ADDED
corvee/__main__.py
ADDED
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,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)
|