coprogrammer 0.2.0a2__tar.gz → 0.3.0a1__tar.gz
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.
- {coprogrammer-0.2.0a2/src/coprogrammer.egg-info → coprogrammer-0.3.0a1}/PKG-INFO +18 -14
- {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1}/README.md +17 -13
- {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1}/pyproject.toml +1 -1
- {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1}/src/coprogrammer/__init__.py +1 -1
- {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1}/src/coprogrammer/assets/skills/coprogrammer-active-sync/SKILL.md +26 -2
- {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1}/src/coprogrammer/assets/templates/handoff.md +8 -0
- {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1}/src/coprogrammer/cli.py +9 -0
- coprogrammer-0.3.0a1/src/coprogrammer/evidence.py +161 -0
- {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1}/src/coprogrammer/manager_store.py +3 -3
- {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1}/src/coprogrammer/mcp_server.py +7 -0
- {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1}/src/coprogrammer/setup.py +15 -7
- coprogrammer-0.3.0a1/src/coprogrammer/workflow.py +191 -0
- coprogrammer-0.3.0a1/src/coprogrammer/workflow_cli.py +93 -0
- coprogrammer-0.3.0a1/src/coprogrammer/workflow_mcp.py +43 -0
- coprogrammer-0.3.0a1/src/coprogrammer/workspace.py +256 -0
- {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1/src/coprogrammer.egg-info}/PKG-INFO +18 -14
- {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1}/src/coprogrammer.egg-info/SOURCES.txt +7 -1
- {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1}/tests/test_manager_store.py +23 -0
- {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1}/tests/test_setup.py +34 -0
- coprogrammer-0.3.0a1/tests/test_workspace_workflow.py +372 -0
- {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1}/LICENSE +0 -0
- {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1}/setup.cfg +0 -0
- {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1}/src/coprogrammer/__main__.py +0 -0
- {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1}/src/coprogrammer/assets/skills/coprogrammer-integration-plan/SKILL.md +0 -0
- {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1}/src/coprogrammer/assets/skills/coprogrammer-pr-digest-review/SKILL.md +0 -0
- {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1}/src/coprogrammer/assets/skills/coprogrammer-project-covenant/SKILL.md +0 -0
- {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1}/src/coprogrammer/assets/skills/coprogrammer-project-setup/SKILL.md +0 -0
- {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1}/src/coprogrammer/assets/skills/coprogrammer-task-brief/SKILL.md +0 -0
- {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1}/src/coprogrammer/assets/templates/AGENTS.md +0 -0
- {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1}/src/coprogrammer/assets/templates/CLAUDE.md +0 -0
- {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1}/src/coprogrammer/assets/templates/COORDINATION.md +0 -0
- {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1}/src/coprogrammer/assets/templates/copilot-instructions.md +0 -0
- {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1}/src/coprogrammer/assets/templates/task-brief.md +0 -0
- {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1}/src/coprogrammer/briefing.py +0 -0
- {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1}/src/coprogrammer/channels.py +0 -0
- {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1}/src/coprogrammer/collaboration.py +0 -0
- {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1}/src/coprogrammer/collaboration_cli.py +0 -0
- {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1}/src/coprogrammer/collaboration_mcp.py +0 -0
- {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1}/src/coprogrammer/demo.py +0 -0
- {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1}/src/coprogrammer/integrations.py +0 -0
- {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1}/src/coprogrammer/knowledge.py +0 -0
- {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1}/src/coprogrammer/knowledge_mcp.py +0 -0
- {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1}/src/coprogrammer/providers.py +0 -0
- {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1}/src/coprogrammer/review.py +0 -0
- {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1}/src/coprogrammer/review_summary.py +0 -0
- {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1}/src/coprogrammer/scheduler.py +0 -0
- {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1}/src/coprogrammer/scheduler_cli.py +0 -0
- {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1}/src/coprogrammer/scheduler_mcp.py +0 -0
- {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1}/src/coprogrammer.egg-info/dependency_links.txt +0 -0
- {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1}/src/coprogrammer.egg-info/entry_points.txt +0 -0
- {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1}/src/coprogrammer.egg-info/top_level.txt +0 -0
- {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1}/tests/test_briefing.py +0 -0
- {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1}/tests/test_channels.py +0 -0
- {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1}/tests/test_cli.py +0 -0
- {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1}/tests/test_collaboration.py +0 -0
- {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1}/tests/test_collaboration_processes.py +0 -0
- {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1}/tests/test_demo.py +0 -0
- {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1}/tests/test_integrations.py +0 -0
- {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1}/tests/test_knowledge.py +0 -0
- {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1}/tests/test_knowledge_mcp.py +0 -0
- {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1}/tests/test_manager_performance.py +0 -0
- {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1}/tests/test_mcp_robustness.py +0 -0
- {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1}/tests/test_plugin_package.py +0 -0
- {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1}/tests/test_providers.py +0 -0
- {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1}/tests/test_review.py +0 -0
- {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1}/tests/test_review_summary.py +0 -0
- {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1}/tests/test_review_summary_cli.py +0 -0
- {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1}/tests/test_scheduler.py +0 -0
- {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1}/tests/test_scheduling_integration.py +0 -0
- {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1}/tests/test_upgrade_cli.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: coprogrammer
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0a1
|
|
4
4
|
Summary: Open protocol and toolkit for semantic integration in multi-agent software development.
|
|
5
5
|
Author: Qiu Guanzong
|
|
6
6
|
License: MIT License
|
|
@@ -57,22 +57,26 @@ specific set of commits. Maintainers decide what reaches `main`.
|
|
|
57
57
|
[Ecosystem comparison](docs/research/ecosystem-2026-09-30.md) ·
|
|
58
58
|
[Contributing](CONTRIBUTING.md) · [MIT](LICENSE)
|
|
59
59
|
|
|
60
|
-
> **0.
|
|
61
|
-
>
|
|
60
|
+
> **v0.3 preview (`0.3.0a1`).** Check [PyPI](https://pypi.org/project/coprogrammer/)
|
|
61
|
+
> for published versions, or use the source installation below. Client configuration
|
|
62
62
|
> generators and provider adapters are implemented; live client/provider
|
|
63
63
|
> verification is still pending.
|
|
64
64
|
|
|
65
|
-
## New in
|
|
66
|
-
|
|
67
|
-
- **
|
|
68
|
-
|
|
69
|
-
- **
|
|
70
|
-
|
|
71
|
-
- **
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
65
|
+
## New in v0.3
|
|
66
|
+
|
|
67
|
+
- **Session-aware dispatch:** preview tasks compatible with this client,
|
|
68
|
+
session and worktree, then claim atomically.
|
|
69
|
+
- **Content-bound checks:** record explicit local commands and detect stale
|
|
70
|
+
results after file edits, command/runtime changes or expiry.
|
|
71
|
+
- **Portable handoffs:** compare versions, exact revisions, task scope and
|
|
72
|
+
content when moving work to another clone or computer. Local ownership remains
|
|
73
|
+
separate and receiving-machine checks are required.
|
|
74
|
+
|
|
75
|
+
See [dispatch and handoff](docs/WORKSPACE_HANDOFF.md),
|
|
76
|
+
[check receipts](docs/VALIDATION_EVIDENCE.md) and the
|
|
77
|
+
[source-backed choices](docs/upgrade-2026-10-04/research.md).
|
|
78
|
+
The offline demo, local messages, review adapters and preview-first setup remain
|
|
79
|
+
available through CLI, MCP and packaged workflows.
|
|
76
80
|
|
|
77
81
|
## When it helps
|
|
78
82
|
|
|
@@ -16,22 +16,26 @@ specific set of commits. Maintainers decide what reaches `main`.
|
|
|
16
16
|
[Ecosystem comparison](docs/research/ecosystem-2026-09-30.md) ·
|
|
17
17
|
[Contributing](CONTRIBUTING.md) · [MIT](LICENSE)
|
|
18
18
|
|
|
19
|
-
> **0.
|
|
20
|
-
>
|
|
19
|
+
> **v0.3 preview (`0.3.0a1`).** Check [PyPI](https://pypi.org/project/coprogrammer/)
|
|
20
|
+
> for published versions, or use the source installation below. Client configuration
|
|
21
21
|
> generators and provider adapters are implemented; live client/provider
|
|
22
22
|
> verification is still pending.
|
|
23
23
|
|
|
24
|
-
## New in
|
|
25
|
-
|
|
26
|
-
- **
|
|
27
|
-
|
|
28
|
-
- **
|
|
29
|
-
|
|
30
|
-
- **
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
24
|
+
## New in v0.3
|
|
25
|
+
|
|
26
|
+
- **Session-aware dispatch:** preview tasks compatible with this client,
|
|
27
|
+
session and worktree, then claim atomically.
|
|
28
|
+
- **Content-bound checks:** record explicit local commands and detect stale
|
|
29
|
+
results after file edits, command/runtime changes or expiry.
|
|
30
|
+
- **Portable handoffs:** compare versions, exact revisions, task scope and
|
|
31
|
+
content when moving work to another clone or computer. Local ownership remains
|
|
32
|
+
separate and receiving-machine checks are required.
|
|
33
|
+
|
|
34
|
+
See [dispatch and handoff](docs/WORKSPACE_HANDOFF.md),
|
|
35
|
+
[check receipts](docs/VALIDATION_EVIDENCE.md) and the
|
|
36
|
+
[source-backed choices](docs/upgrade-2026-10-04/research.md).
|
|
37
|
+
The offline demo, local messages, review adapters and preview-first setup remain
|
|
38
|
+
available through CLI, MCP and packaged workflows.
|
|
35
39
|
|
|
36
40
|
## When it helps
|
|
37
41
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "coprogrammer"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.3.0a1"
|
|
8
8
|
description = "Open protocol and toolkit for semantic integration in multi-agent software development."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -16,8 +16,9 @@ touch overlapping code or shared contracts.
|
|
|
16
16
|
|
|
17
17
|
## Workflow
|
|
18
18
|
|
|
19
|
-
For cross-window work,
|
|
20
|
-
|
|
19
|
+
For cross-window work, check `coprogrammer --version` in each environment.
|
|
20
|
+
Use CoProgrammer 0.3+ for dispatch previews, content-bound checks and portable
|
|
21
|
+
handoffs; existing session/claim/message commands remain available. Keep one session ID per window, even when several
|
|
21
22
|
windows use the same client. Register once in that window's working tree; resume
|
|
22
23
|
with `pulse`, not another registration:
|
|
23
24
|
|
|
@@ -33,6 +34,8 @@ The inbox snapshot still includes unacknowledged messages after cursor advanceme
|
|
|
33
34
|
`freshness` measures explicit pulses, not process liveness; reads do not keep a
|
|
34
35
|
session alive. Git worktrees share Manager state; independent clones do not.
|
|
35
36
|
|
|
37
|
+
Preview eligible work with `coprogrammer manager dispatch --session <window-id>`.
|
|
38
|
+
This read does not claim or pulse; claim always rechecks constraints atomically.
|
|
36
39
|
For queued work, create scoped tasks with `manager task create`, optional
|
|
37
40
|
`--depends-on` and `--client` eligibility. Use `manager task claim` to atomically
|
|
38
41
|
claim a task and its path lease; preserve the returned `claim_id`. Do not request
|
|
@@ -53,6 +56,27 @@ they do not intercept writes or replace code review. Use a named Git branch,
|
|
|
53
56
|
not detached HEAD. Pulse before the session becomes stale and renew claims
|
|
54
57
|
before expiry; sync does neither.
|
|
55
58
|
|
|
59
|
+
Record explicit noninteractive checks with content-bound receipts before reporting
|
|
60
|
+
validation. Use an ignored directory or output outside the checkout:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
coprogrammer check run --label tests --output .coprogrammer/checks/tests.json \
|
|
64
|
+
-- python -m unittest discover -s tests
|
|
65
|
+
coprogrammer check verify --artifact .coprogrammer/checks/tests.json \
|
|
66
|
+
-- python -m unittest discover -s tests
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
A failed, changed, expired or missing record cannot establish a current pass.
|
|
70
|
+
Ignored files and external inputs are outside the fingerprint; receipts do not
|
|
71
|
+
authenticate the worker. MCP `check_verify` never runs a command.
|
|
72
|
+
|
|
73
|
+
For another computer, reconcile and stop the source worker, commit intended
|
|
74
|
+
changes, and export `manager handoff --session <window-id> --task <task-id>
|
|
75
|
+
--base origin/main --check <receipt>` as JSON to an ignored/external path. The
|
|
76
|
+
receiver uses `workspace compare <bundle>` after an explicit Git fetch/checkout.
|
|
77
|
+
Context alignment transfers no ownership: rerun local checks and acquire a local
|
|
78
|
+
claim. Independent clones keep independent Managers; never copy tokens or logs.
|
|
79
|
+
|
|
56
80
|
Report completed work and actual validation with `manager task finish --summary
|
|
57
81
|
<result>`; use `task release` with a summary to requeue unfinished work. Both
|
|
58
82
|
require `--session`, `--id` and `--claim`. Completion is self-reported and does
|
|
@@ -23,3 +23,11 @@ action. Include any files that need reconciliation before another worker edits.
|
|
|
23
23
|
State whether the task was finished, released or still claimed, and whether
|
|
24
24
|
message receipt is pending. A handoff does not transfer a lease; the recipient
|
|
25
25
|
must sync and acquire their own task claim before editing.
|
|
26
|
+
|
|
27
|
+
## Portable context (CoProgrammer 0.3+)
|
|
28
|
+
|
|
29
|
+
Record `coprogrammer --version`, source HEAD/base, dirty state and content-bound
|
|
30
|
+
check statuses. Export `manager handoff` as JSON to an ignored/external path;
|
|
31
|
+
inspect it with `workspace compare` on the receiving machine. Confirm the source
|
|
32
|
+
worker stopped, run receiving-machine checks and acquire a local claim.
|
|
33
|
+
Context alignment does not transfer ownership between independent Managers.
|
|
@@ -401,12 +401,17 @@ def utc_now() -> str:
|
|
|
401
401
|
|
|
402
402
|
|
|
403
403
|
def run_git(args: list[str], cwd: Path) -> str:
|
|
404
|
+
# A caller's Git redirection must never move a requested project operation
|
|
405
|
+
# into another window's repository or index.
|
|
406
|
+
env = {key: value for key, value in os.environ.items() if not key.startswith("GIT_")}
|
|
407
|
+
env.update(GIT_OPTIONAL_LOCKS="0", GIT_TERMINAL_PROMPT="0")
|
|
404
408
|
result = subprocess.run(
|
|
405
409
|
["git", *args],
|
|
406
410
|
cwd=cwd,
|
|
407
411
|
check=False,
|
|
408
412
|
text=True,
|
|
409
413
|
capture_output=True,
|
|
414
|
+
env=env,
|
|
410
415
|
)
|
|
411
416
|
if result.returncode != 0:
|
|
412
417
|
raise RuntimeError(result.stderr.strip() or "git command failed")
|
|
@@ -1785,6 +1790,8 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
1785
1790
|
description="Protocol tooling for semantic branch integration.",
|
|
1786
1791
|
)
|
|
1787
1792
|
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
1793
|
+
from . import __version__
|
|
1794
|
+
parser.add_argument("--version", action="version", version=__version__)
|
|
1788
1795
|
|
|
1789
1796
|
demo = subparsers.add_parser("demo", help="Run a real offline coordination demo in temporary Git worktrees.")
|
|
1790
1797
|
demo.add_argument("--json", action="store_true")
|
|
@@ -1961,6 +1968,8 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
1961
1968
|
add_parsers(manager_subparsers)
|
|
1962
1969
|
from .scheduler_cli import add_parsers as add_task_parsers
|
|
1963
1970
|
add_task_parsers(manager_subparsers)
|
|
1971
|
+
from .workflow_cli import add_parsers as add_workflow_parsers
|
|
1972
|
+
add_workflow_parsers(subparsers, manager_subparsers)
|
|
1964
1973
|
|
|
1965
1974
|
briefing = manager_subparsers.add_parser("briefing", help="Summarize ready work, blockers and next actions.")
|
|
1966
1975
|
briefing.add_argument("--cwd", default=".")
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Explicit local check receipts bound to working content and command hashes.
|
|
2
|
+
|
|
3
|
+
Receipts are unauthenticated local evidence. Ignored files, external inputs and
|
|
4
|
+
transient edits restored between observations are outside this boundary.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import platform
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
import tempfile
|
|
14
|
+
from datetime import datetime, timezone
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from . import __version__, collaboration as co, workspace as ws
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def environment() -> dict:
|
|
21
|
+
return {"tool_version": __version__, "python": platform.python_version(), "platform": sys.platform}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def command_digest(command: list[str]) -> str:
|
|
25
|
+
if not isinstance(command, list) or not 1 <= len(command) <= 128:
|
|
26
|
+
raise RuntimeError("check requires an explicit command argument vector")
|
|
27
|
+
for arg in command:
|
|
28
|
+
co.text(arg, "command argument", 4096, empty=True)
|
|
29
|
+
if "\0" in arg:
|
|
30
|
+
raise RuntimeError("invalid command argument")
|
|
31
|
+
if not command[0].strip():
|
|
32
|
+
raise RuntimeError("command executable is required")
|
|
33
|
+
return ws.digest(command)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _destination(cwd: Path, path: Path, label: str) -> Path:
|
|
37
|
+
path = path.absolute()
|
|
38
|
+
if path.is_symlink():
|
|
39
|
+
raise RuntimeError("check output must not be a symlink")
|
|
40
|
+
if path.resolve().is_relative_to(cwd):
|
|
41
|
+
relative = path.resolve().relative_to(cwd).as_posix()
|
|
42
|
+
if (ws.git(cwd, "ls-files", "--", relative).strip()
|
|
43
|
+
or not ws.git(cwd, "check-ignore", "--no-index", "--", relative, optional=True).strip()):
|
|
44
|
+
raise RuntimeError("check output must be ignored by Git or outside the repository")
|
|
45
|
+
if path.exists():
|
|
46
|
+
previous = ws.read_artifact(path)
|
|
47
|
+
if previous.get("format") != "coprogrammer.check.v1" or previous.get("label") != label:
|
|
48
|
+
raise RuntimeError("refusing to overwrite a different artifact")
|
|
49
|
+
return path
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def run(cwd: Path, command: list[str], label: str, output: Path, timeout_seconds: int = 300) -> dict:
|
|
53
|
+
co.identity(label, "check label")
|
|
54
|
+
co.bounded(timeout_seconds, "timeout_seconds", 3600)
|
|
55
|
+
command_hash = command_digest(command)
|
|
56
|
+
cwd = ws.root(cwd)
|
|
57
|
+
output = _destination(cwd, output, label)
|
|
58
|
+
subject = ws.snapshot(cwd)
|
|
59
|
+
started_at = datetime.now(timezone.utc).isoformat()
|
|
60
|
+
exit_code, timed_out, error = None, False, None
|
|
61
|
+
try:
|
|
62
|
+
# No shell interpretation, interactive input, output/argv persistence,
|
|
63
|
+
# or provider invocation implicit in the recording operation.
|
|
64
|
+
exit_code = subprocess.run(command, cwd=cwd, stdin=subprocess.DEVNULL,
|
|
65
|
+
stdout=sys.stderr, stderr=sys.stderr, timeout=timeout_seconds,
|
|
66
|
+
check=False).returncode
|
|
67
|
+
except subprocess.TimeoutExpired:
|
|
68
|
+
timed_out = True
|
|
69
|
+
error = "command timed out; inspect any surviving child processes"
|
|
70
|
+
except OSError:
|
|
71
|
+
error = "command could not start"
|
|
72
|
+
after = None
|
|
73
|
+
try:
|
|
74
|
+
after = ws.snapshot(cwd)["content"]["sha256"]
|
|
75
|
+
except (RuntimeError, OSError):
|
|
76
|
+
error = error or "workspace unavailable after command"
|
|
77
|
+
record = {"format": "coprogrammer.check.v1", "label": label,
|
|
78
|
+
"command_sha256": command_hash, "started_at": started_at,
|
|
79
|
+
"finished_at": datetime.now(timezone.utc).isoformat(),
|
|
80
|
+
"exit_code": exit_code, "timed_out": timed_out, "error": error,
|
|
81
|
+
"subject": subject, "after_sha256": after,
|
|
82
|
+
"stable": after == subject["content"]["sha256"], "environment": environment()}
|
|
83
|
+
record["integrity_sha256"] = ws.digest(record)
|
|
84
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
85
|
+
temporary = None
|
|
86
|
+
try:
|
|
87
|
+
with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", dir=output.parent,
|
|
88
|
+
prefix=".coprogrammer-check-", delete=False) as handle:
|
|
89
|
+
temporary = Path(handle.name)
|
|
90
|
+
handle.write(json.dumps(record, ensure_ascii=False, indent=2, allow_nan=False) + "\n")
|
|
91
|
+
handle.flush()
|
|
92
|
+
os.fsync(handle.fileno())
|
|
93
|
+
os.replace(temporary, output)
|
|
94
|
+
finally:
|
|
95
|
+
if temporary is not None:
|
|
96
|
+
temporary.unlink(missing_ok=True)
|
|
97
|
+
return record
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def validate(record: object) -> dict:
|
|
101
|
+
try:
|
|
102
|
+
fields = {"format", "label", "command_sha256", "started_at", "finished_at", "exit_code", "timed_out", "error", "subject", "after_sha256", "stable", "environment", "integrity_sha256"}
|
|
103
|
+
if not isinstance(record, dict) or set(record) != fields or record["format"] != "coprogrammer.check.v1":
|
|
104
|
+
raise ValueError
|
|
105
|
+
co.identity(record["label"], "check label")
|
|
106
|
+
ws.validate_snapshot(record["subject"])
|
|
107
|
+
if not ws.HASH.fullmatch(record["command_sha256"]) or not ws.HASH.fullmatch(record["integrity_sha256"]):
|
|
108
|
+
raise ValueError
|
|
109
|
+
if record["after_sha256"] is not None and not ws.HASH.fullmatch(record["after_sha256"]):
|
|
110
|
+
raise ValueError
|
|
111
|
+
if type(record["timed_out"]) is not bool or type(record["stable"]) is not bool:
|
|
112
|
+
raise ValueError
|
|
113
|
+
if record["exit_code"] is not None and (type(record["exit_code"]) is not int or not -2**31 <= record["exit_code"] < 2**31):
|
|
114
|
+
raise ValueError
|
|
115
|
+
if record["error"] is not None:
|
|
116
|
+
co.text(record["error"], "check error", 256)
|
|
117
|
+
if co.timestamp(record["finished_at"]) < co.timestamp(record["started_at"]):
|
|
118
|
+
raise ValueError
|
|
119
|
+
env = record["environment"]
|
|
120
|
+
if set(env) != {"tool_version", "python", "platform"}:
|
|
121
|
+
raise ValueError
|
|
122
|
+
for value in env.values():
|
|
123
|
+
co.text(value, "check environment", 64)
|
|
124
|
+
if record["stable"] != (record["after_sha256"] == record["subject"]["content"]["sha256"]):
|
|
125
|
+
raise ValueError
|
|
126
|
+
if record["integrity_sha256"] != ws.digest({k: v for k, v in record.items() if k != "integrity_sha256"}):
|
|
127
|
+
raise ValueError
|
|
128
|
+
except (ValueError, TypeError, KeyError, AttributeError, RuntimeError) as exc:
|
|
129
|
+
raise RuntimeError("invalid local check record or integrity mismatch") from exc
|
|
130
|
+
return record
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def verify(cwd: Path, path: Path, command: list[str] | None = None, *, current: dict | None = None,
|
|
134
|
+
max_age_seconds: int = 86400) -> dict:
|
|
135
|
+
co.bounded(max_age_seconds, "max_age_seconds", 604800)
|
|
136
|
+
record = validate(ws.read_artifact(path))
|
|
137
|
+
current = ws.snapshot(cwd) if current is None else current
|
|
138
|
+
reasons = []
|
|
139
|
+
age = (datetime.now(timezone.utc) - co.timestamp(record["finished_at"])).total_seconds()
|
|
140
|
+
if age < 0:
|
|
141
|
+
reasons.append("record_from_future")
|
|
142
|
+
elif age > max_age_seconds:
|
|
143
|
+
reasons.append("record_expired")
|
|
144
|
+
if record["exit_code"] != 0 or record["timed_out"] or record["error"]:
|
|
145
|
+
reasons.append("check_failed")
|
|
146
|
+
if not record["stable"]:
|
|
147
|
+
reasons.append("content_changed_during_check")
|
|
148
|
+
if record["subject"]["content"]["sha256"] != current["content"]["sha256"]:
|
|
149
|
+
reasons.append("content_changed")
|
|
150
|
+
if not ws.same_repository(record["subject"], current):
|
|
151
|
+
reasons.append("repository_changed")
|
|
152
|
+
if record["environment"] != environment():
|
|
153
|
+
reasons.append("environment_changed")
|
|
154
|
+
if command is not None and command_digest(command) != record["command_sha256"]:
|
|
155
|
+
reasons.append("command_changed")
|
|
156
|
+
return {"format": "coprogrammer.check-status.v1", "label": record["label"],
|
|
157
|
+
"fresh": not reasons, "reasons": reasons, "exit_code": record["exit_code"],
|
|
158
|
+
"finished_at": record["finished_at"], "command_sha256": record["command_sha256"],
|
|
159
|
+
"expected_command_checked": command is not None,
|
|
160
|
+
"content_sha256": record["subject"]["content"]["sha256"],
|
|
161
|
+
"note": "Unauthenticated local record; ignored files, dependencies and external inputs are not covered."}
|
|
@@ -206,9 +206,9 @@ def transaction(path: Path, timeout: float = DEFAULT_LOCK_TIMEOUT) -> Iterator[E
|
|
|
206
206
|
try:
|
|
207
207
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
208
208
|
with path.with_name(path.name + ".lock").open("a+b") as handle:
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
209
|
+
# Windows permits locking beyond EOF. Bootstrap writes before the
|
|
210
|
+
# byte lock can conflict with another owner; keep even a new lock
|
|
211
|
+
# file empty and acquire ownership before accessing Manager state.
|
|
212
212
|
_lock(handle, deadline)
|
|
213
213
|
try:
|
|
214
214
|
current = EventTransaction(path)
|
|
@@ -30,6 +30,7 @@ from . import __version__
|
|
|
30
30
|
from . import collaboration_mcp
|
|
31
31
|
from . import scheduler_mcp
|
|
32
32
|
from . import knowledge_mcp
|
|
33
|
+
from . import workflow_mcp
|
|
33
34
|
|
|
34
35
|
SUPPORTED_PROTOCOL_VERSIONS = (
|
|
35
36
|
"2024-11-05", "2025-03-26", "2025-06-18", "2025-11-25",
|
|
@@ -227,8 +228,10 @@ TOOLS: list[dict[str, Any]] = [
|
|
|
227
228
|
|
|
228
229
|
TOOLS.extend(collaboration_mcp.declarations(string_schema))
|
|
229
230
|
TOOLS.extend(scheduler_mcp.declarations(string_schema))
|
|
231
|
+
TOOLS.extend(workflow_mcp.declarations(string_schema))
|
|
230
232
|
READ_ONLY_TOOLS = {"digest_branch", "manager_status", "manager_briefing", "manager_forecast", "review_summary",
|
|
231
233
|
"manager_sync", "message_inbox", "manager_wait", "message_thread", "task_board", "task_guard"}
|
|
234
|
+
READ_ONLY_TOOLS.update({"workspace_snapshot", "task_dispatch", "manager_handoff", "handoff_check", "check_verify"})
|
|
232
235
|
for _tool in TOOLS:
|
|
233
236
|
_tool["inputSchema"]["additionalProperties"] = False
|
|
234
237
|
_tool["annotations"] = {
|
|
@@ -421,6 +424,10 @@ for _name in ("task_create", "task_claim", "task_renew", "task_finish", "task_re
|
|
|
421
424
|
TOOL_HANDLERS[_name] = lambda ctx, args, name=_name: json.dumps(
|
|
422
425
|
scheduler_mcp.dispatch(name, ctx, args), indent=2, ensure_ascii=False)
|
|
423
426
|
|
|
427
|
+
for _name in ("workspace_snapshot", "task_dispatch", "manager_handoff", "handoff_check", "check_verify"):
|
|
428
|
+
TOOL_HANDLERS[_name] = lambda ctx, args, name=_name: json.dumps(
|
|
429
|
+
workflow_mcp.dispatch(name, ctx, args), indent=2, ensure_ascii=False)
|
|
430
|
+
|
|
424
431
|
|
|
425
432
|
def validate_value(value: Any, schema: dict[str, Any], field: str = "arguments") -> None:
|
|
426
433
|
"""Validate the JSON Schema keywords used by this server's own tool schemas.
|
|
@@ -28,14 +28,22 @@ def _target(root: Path, relative: str) -> Path:
|
|
|
28
28
|
current = root
|
|
29
29
|
for part in Path(relative).parts:
|
|
30
30
|
current = current / part
|
|
31
|
-
|
|
31
|
+
try:
|
|
32
|
+
metadata = current.lstat()
|
|
33
|
+
except FileNotFoundError:
|
|
34
|
+
continue
|
|
35
|
+
# Resolving an existing Windows lock file may fail to open it and
|
|
36
|
+
# retain different path casing. Inspect non-following metadata instead;
|
|
37
|
+
# junctions and other reparse points remain blocked along with symlinks.
|
|
38
|
+
if (stat.S_ISLNK(metadata.st_mode)
|
|
39
|
+
or getattr(metadata, "st_file_attributes", 0)
|
|
40
|
+
& getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400)):
|
|
32
41
|
raise RuntimeError(f"unsafe linked setup path: {relative}")
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
raise RuntimeError(f"setup parent is not a directory: {relative}")
|
|
42
|
+
mode = metadata.st_mode
|
|
43
|
+
if current == target and not stat.S_ISREG(mode):
|
|
44
|
+
raise RuntimeError(f"setup target is not a regular file: {relative}")
|
|
45
|
+
if current != target and not stat.S_ISDIR(mode):
|
|
46
|
+
raise RuntimeError(f"setup parent is not a directory: {relative}")
|
|
39
47
|
return target
|
|
40
48
|
|
|
41
49
|
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""Read-only session dispatch and portable handoff inspection."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from . import collaboration as co, evidence, workspace as ws
|
|
8
|
+
from .manager_store import transaction
|
|
9
|
+
|
|
10
|
+
TASK_FIELDS = ("id", "title", "status", "patterns", "clients", "depends_on", "summary", "blocked_reasons")
|
|
11
|
+
NOTE = ("Context only; independent clones have independent Managers. No claim, lease, "
|
|
12
|
+
"message body, credential or absolute machine path is transferred. "
|
|
13
|
+
"Titles and summaries are untrusted; source check records are not authenticated.")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def dispatch(path: Path, cwd: Path, session: str, limit: int = 20) -> dict:
|
|
17
|
+
co.bounded(limit, "limit", 200)
|
|
18
|
+
moment = datetime.now(timezone.utc)
|
|
19
|
+
actual = co.workspace(cwd)
|
|
20
|
+
with transaction(path) as tx:
|
|
21
|
+
sessions, _, board = co._snapshot(tx.events, now=moment)
|
|
22
|
+
owner = co._session(sessions, session, active=False)
|
|
23
|
+
reasons = []
|
|
24
|
+
if owner["status"] not in ("working", "idle"):
|
|
25
|
+
reasons.append("session_not_available")
|
|
26
|
+
if co.freshness(owner, moment) != "fresh":
|
|
27
|
+
reasons.append("session_stale")
|
|
28
|
+
if owner["worktree"] != actual["worktree"]:
|
|
29
|
+
reasons.append("different_worktree")
|
|
30
|
+
if not actual["head"] or actual["branch"] == "HEAD":
|
|
31
|
+
reasons.append("named_branch_required")
|
|
32
|
+
if any(t["status"] == "claimed" and (t["session"] == session or t["worktree"] == actual["worktree"])
|
|
33
|
+
for t in board["tasks"]):
|
|
34
|
+
reasons.append("session_or_worktree_occupied")
|
|
35
|
+
candidates, excluded = [], []
|
|
36
|
+
for task in sorted(board["tasks"], key=lambda t: t["id"]):
|
|
37
|
+
blocked = list(task["blocked_reasons"])
|
|
38
|
+
if task["status"] != "queued":
|
|
39
|
+
blocked.append("task_not_queued")
|
|
40
|
+
if task["clients"] and owner["client"].strip().casefold() not in task["clients"]:
|
|
41
|
+
blocked.append("client_not_eligible")
|
|
42
|
+
blocked = list(dict.fromkeys([*reasons, *blocked]))
|
|
43
|
+
row = {"id": task["id"], "title": task["title"], "clients": task["clients"],
|
|
44
|
+
"patterns": task["patterns"], "reasons": blocked}
|
|
45
|
+
(excluded if blocked else candidates).append(row)
|
|
46
|
+
return {"format": "coprogrammer.dispatch.v1", "session": session, "client": owner["client"],
|
|
47
|
+
"session_reasons": reasons, "recommended_task": candidates[0]["id"] if candidates else None,
|
|
48
|
+
"counts": {"eligible": len(candidates), "excluded": len(excluded)},
|
|
49
|
+
"candidates": candidates[:limit], "excluded": excluded[:limit],
|
|
50
|
+
"omitted": {"candidates": max(0, len(candidates) - limit), "excluded": max(0, len(excluded) - limit)},
|
|
51
|
+
"note": "Read-only preview. Explicitly claim the selected task; claim rechecks all constraints atomically."}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def handoff(path: Path, cwd: Path, session: str, task_id: str, base: str = "HEAD",
|
|
55
|
+
checks: list[Path] | None = None) -> dict:
|
|
56
|
+
co.identity(task_id, "task_id")
|
|
57
|
+
checks = [] if checks is None else checks
|
|
58
|
+
if len(checks) > 16:
|
|
59
|
+
raise RuntimeError("handoff supports at most 16 check records")
|
|
60
|
+
current = ws.snapshot(cwd, base)
|
|
61
|
+
with transaction(path) as tx:
|
|
62
|
+
sessions, _, board = co._snapshot(tx.events)
|
|
63
|
+
owner = co._session(sessions, session, cwd, active=False)
|
|
64
|
+
task = next((t for t in board["tasks"] if t["id"] == task_id), None)
|
|
65
|
+
if task is None:
|
|
66
|
+
raise RuntimeError("unknown handoff task")
|
|
67
|
+
if session not in (task["creator"], task["session"]):
|
|
68
|
+
raise RuntimeError("handoff session must be the task creator or recorded owner")
|
|
69
|
+
if task["status"] in ("claimed", "done") and (
|
|
70
|
+
task["session"] != session or task["worktree"] != str(ws.root(cwd))
|
|
71
|
+
or task["branch"] != current["branch"]):
|
|
72
|
+
raise RuntimeError("handoff must use the recorded task owner's worktree and branch")
|
|
73
|
+
selected = {key: task[key] for key in TASK_FIELDS}
|
|
74
|
+
window = {"id": session, "client": owner["client"], "status": owner["status"],
|
|
75
|
+
"freshness": co.freshness(owner)}
|
|
76
|
+
statuses = [evidence.verify(cwd, record, current=current) for record in checks]
|
|
77
|
+
if len({s["label"] for s in statuses}) != len(statuses):
|
|
78
|
+
raise RuntimeError("handoff check labels must be unique")
|
|
79
|
+
# The store read does not lock the workspace. Check its observation again.
|
|
80
|
+
if ws.snapshot(cwd, base) != current:
|
|
81
|
+
raise RuntimeError("workspace changed while preparing handoff")
|
|
82
|
+
result = {"format": "coprogrammer.handoff.v1", "generated_at": datetime.now(timezone.utc).isoformat(),
|
|
83
|
+
"workspace": current, "session": window, "task": selected, "checks": statuses, "note": NOTE}
|
|
84
|
+
result["integrity_sha256"] = ws.digest(result)
|
|
85
|
+
return result
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def validate_handoff(value: object) -> dict:
|
|
89
|
+
try:
|
|
90
|
+
if not isinstance(value, dict) or set(value) != {"format", "generated_at", "workspace", "session", "task", "checks", "note", "integrity_sha256"}:
|
|
91
|
+
raise ValueError
|
|
92
|
+
if value["format"] != "coprogrammer.handoff.v1":
|
|
93
|
+
raise ValueError
|
|
94
|
+
ws.validate_snapshot(value["workspace"])
|
|
95
|
+
co.timestamp(value["generated_at"])
|
|
96
|
+
if value["integrity_sha256"] != ws.digest({k: v for k, v in value.items() if k != "integrity_sha256"}):
|
|
97
|
+
raise ValueError
|
|
98
|
+
window, task = value["session"], value["task"]
|
|
99
|
+
if set(window) != {"id", "client", "status", "freshness"} or set(task) != set(TASK_FIELDS):
|
|
100
|
+
raise ValueError
|
|
101
|
+
co.identity(window["id"])
|
|
102
|
+
co.text(window["client"], "client")
|
|
103
|
+
if window["status"] not in ("working", "idle", "blocked", "closed") or window["freshness"] not in ("fresh", "stale", "unknown"):
|
|
104
|
+
raise ValueError
|
|
105
|
+
co.identity(task["id"], "task_id")
|
|
106
|
+
co.text(task["title"], "task title", 2000)
|
|
107
|
+
co.text(task["summary"], "task summary", 16000, empty=True)
|
|
108
|
+
if task["status"] not in ("queued", "claimed", "done"):
|
|
109
|
+
raise ValueError
|
|
110
|
+
from .scheduler import _patterns, _strings
|
|
111
|
+
_patterns(task["patterns"])
|
|
112
|
+
for field in ("clients", "depends_on", "blocked_reasons"):
|
|
113
|
+
_strings(task[field], field)
|
|
114
|
+
co.text(value["note"], "handoff note", 2000)
|
|
115
|
+
checks = value["checks"]
|
|
116
|
+
if not isinstance(checks, list) or len(checks) > 16:
|
|
117
|
+
raise ValueError
|
|
118
|
+
for check in checks:
|
|
119
|
+
if set(check) != {"format", "label", "fresh", "reasons", "exit_code", "finished_at", "command_sha256", "expected_command_checked", "content_sha256", "note"}:
|
|
120
|
+
raise ValueError
|
|
121
|
+
co.identity(check["label"], "check label")
|
|
122
|
+
if check["format"] != "coprogrammer.check-status.v1" or type(check["fresh"]) is not bool or type(check["expected_command_checked"]) is not bool:
|
|
123
|
+
raise ValueError
|
|
124
|
+
_strings(check["reasons"], "check reasons")
|
|
125
|
+
if check["fresh"] != (not check["reasons"]) or (check["fresh"] and check["exit_code"] != 0):
|
|
126
|
+
raise ValueError
|
|
127
|
+
if check["exit_code"] is not None and type(check["exit_code"]) is not int:
|
|
128
|
+
raise ValueError
|
|
129
|
+
if not ws.HASH.fullmatch(check["command_sha256"]) or not ws.HASH.fullmatch(check["content_sha256"]):
|
|
130
|
+
raise ValueError
|
|
131
|
+
if check["fresh"] and check["content_sha256"] != value["workspace"]["content"]["sha256"]:
|
|
132
|
+
raise ValueError
|
|
133
|
+
co.timestamp(check["finished_at"])
|
|
134
|
+
co.text(check["note"], "check note", 2000)
|
|
135
|
+
if len({c["label"] for c in checks}) != len(checks):
|
|
136
|
+
raise ValueError
|
|
137
|
+
except (TypeError, ValueError, KeyError, AttributeError, RuntimeError) as exc:
|
|
138
|
+
raise RuntimeError("invalid handoff bundle or integrity mismatch") from exc
|
|
139
|
+
return value
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def compare(cwd: Path, path: Path, base: str | None = None) -> dict:
|
|
143
|
+
bundle = validate_handoff(ws.read_artifact(path))
|
|
144
|
+
source = bundle["workspace"]
|
|
145
|
+
base = source["base"]["ref"] if base is None else base
|
|
146
|
+
current = ws.snapshot(cwd)
|
|
147
|
+
reasons = []
|
|
148
|
+
try:
|
|
149
|
+
base_head = ws.revision(ws.root(cwd), base)
|
|
150
|
+
except RuntimeError:
|
|
151
|
+
base_head = None
|
|
152
|
+
reasons.append("base_unavailable")
|
|
153
|
+
if not ws.same_repository(source, current):
|
|
154
|
+
reasons.append("repository_mismatch")
|
|
155
|
+
if source["head"] != current["head"]:
|
|
156
|
+
reasons.append("head_mismatch")
|
|
157
|
+
if base_head is not None and base_head != source["base"]["head"]:
|
|
158
|
+
reasons.append("base_mismatch")
|
|
159
|
+
if source["tool_version"] != current["tool_version"]:
|
|
160
|
+
reasons.append("tool_version_mismatch")
|
|
161
|
+
if source["content"]["sha256"] != current["content"]["sha256"]:
|
|
162
|
+
reasons.append("content_mismatch")
|
|
163
|
+
if source["dirty"]["count"]:
|
|
164
|
+
reasons.append("source_uncommitted_changes")
|
|
165
|
+
if current["dirty"]["count"]:
|
|
166
|
+
reasons.append("receiver_uncommitted_changes")
|
|
167
|
+
return {"format": "coprogrammer.handoff-check.v1", "aligned": not reasons, "reasons": reasons,
|
|
168
|
+
"task_id": bundle["task"]["id"], "source_head": source["head"], "receiver_head": current["head"],
|
|
169
|
+
"receiver_base": base_head, "source_checks": bundle["checks"],
|
|
170
|
+
"ownership_reconciliation_required": bundle["task"]["status"] == "claimed",
|
|
171
|
+
"ownership_transferred": False, "receiver_checks_required": True,
|
|
172
|
+
"note": "Comparison of untrusted context, not approval. Refresh refs explicitly, reconcile the source owner, run local checks and acquire a local claim before editing."}
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def render_handoff(bundle: dict) -> str:
|
|
176
|
+
task, workspace = bundle["task"], bundle["workspace"]
|
|
177
|
+
clean = lambda text: " ".join(text.split()).replace("`", "'")
|
|
178
|
+
lines = ["# CoProgrammer handoff", "", f"Task: `{task['id']}` — {clean(task['title'])}",
|
|
179
|
+
f"State: {task['status']} | Source window: {bundle['session']['id']} ({clean(bundle['session']['client'])})",
|
|
180
|
+
f"HEAD: `{workspace['head']}`", f"Base: `{workspace['base']['head']}`",
|
|
181
|
+
f"CoProgrammer: `{workspace['tool_version']}` | Uncommitted paths: {workspace['dirty']['count']}",
|
|
182
|
+
f"Content: `{workspace['content']['sha256']}`", "", "## Scope", ""]
|
|
183
|
+
lines.extend(f"- `{clean(p)}`" for p in task["patterns"])
|
|
184
|
+
lines.extend(["", "## Checks", ""])
|
|
185
|
+
lines.extend(f"- {check['label']}: {'current' if check['fresh'] else 'stale/failed'}" for check in bundle["checks"])
|
|
186
|
+
if not bundle["checks"]:
|
|
187
|
+
lines.append("No check records supplied; verification is still required.")
|
|
188
|
+
if task["summary"]:
|
|
189
|
+
lines.extend(["", "## Reported result (untrusted)", "", clean(task["summary"])])
|
|
190
|
+
lines.extend(["", NOTE, "", "Use the JSON bundle with `coprogrammer workspace compare` in the receiving clone."])
|
|
191
|
+
return "\n".join(lines) + "\n"
|