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.
Files changed (70) hide show
  1. {coprogrammer-0.2.0a2/src/coprogrammer.egg-info → coprogrammer-0.3.0a1}/PKG-INFO +18 -14
  2. {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1}/README.md +17 -13
  3. {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1}/pyproject.toml +1 -1
  4. {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1}/src/coprogrammer/__init__.py +1 -1
  5. {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1}/src/coprogrammer/assets/skills/coprogrammer-active-sync/SKILL.md +26 -2
  6. {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1}/src/coprogrammer/assets/templates/handoff.md +8 -0
  7. {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1}/src/coprogrammer/cli.py +9 -0
  8. coprogrammer-0.3.0a1/src/coprogrammer/evidence.py +161 -0
  9. {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1}/src/coprogrammer/manager_store.py +3 -3
  10. {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1}/src/coprogrammer/mcp_server.py +7 -0
  11. {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1}/src/coprogrammer/setup.py +15 -7
  12. coprogrammer-0.3.0a1/src/coprogrammer/workflow.py +191 -0
  13. coprogrammer-0.3.0a1/src/coprogrammer/workflow_cli.py +93 -0
  14. coprogrammer-0.3.0a1/src/coprogrammer/workflow_mcp.py +43 -0
  15. coprogrammer-0.3.0a1/src/coprogrammer/workspace.py +256 -0
  16. {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1/src/coprogrammer.egg-info}/PKG-INFO +18 -14
  17. {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1}/src/coprogrammer.egg-info/SOURCES.txt +7 -1
  18. {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1}/tests/test_manager_store.py +23 -0
  19. {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1}/tests/test_setup.py +34 -0
  20. coprogrammer-0.3.0a1/tests/test_workspace_workflow.py +372 -0
  21. {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1}/LICENSE +0 -0
  22. {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1}/setup.cfg +0 -0
  23. {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1}/src/coprogrammer/__main__.py +0 -0
  24. {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1}/src/coprogrammer/assets/skills/coprogrammer-integration-plan/SKILL.md +0 -0
  25. {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1}/src/coprogrammer/assets/skills/coprogrammer-pr-digest-review/SKILL.md +0 -0
  26. {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1}/src/coprogrammer/assets/skills/coprogrammer-project-covenant/SKILL.md +0 -0
  27. {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1}/src/coprogrammer/assets/skills/coprogrammer-project-setup/SKILL.md +0 -0
  28. {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1}/src/coprogrammer/assets/skills/coprogrammer-task-brief/SKILL.md +0 -0
  29. {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1}/src/coprogrammer/assets/templates/AGENTS.md +0 -0
  30. {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1}/src/coprogrammer/assets/templates/CLAUDE.md +0 -0
  31. {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1}/src/coprogrammer/assets/templates/COORDINATION.md +0 -0
  32. {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1}/src/coprogrammer/assets/templates/copilot-instructions.md +0 -0
  33. {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1}/src/coprogrammer/assets/templates/task-brief.md +0 -0
  34. {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1}/src/coprogrammer/briefing.py +0 -0
  35. {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1}/src/coprogrammer/channels.py +0 -0
  36. {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1}/src/coprogrammer/collaboration.py +0 -0
  37. {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1}/src/coprogrammer/collaboration_cli.py +0 -0
  38. {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1}/src/coprogrammer/collaboration_mcp.py +0 -0
  39. {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1}/src/coprogrammer/demo.py +0 -0
  40. {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1}/src/coprogrammer/integrations.py +0 -0
  41. {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1}/src/coprogrammer/knowledge.py +0 -0
  42. {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1}/src/coprogrammer/knowledge_mcp.py +0 -0
  43. {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1}/src/coprogrammer/providers.py +0 -0
  44. {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1}/src/coprogrammer/review.py +0 -0
  45. {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1}/src/coprogrammer/review_summary.py +0 -0
  46. {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1}/src/coprogrammer/scheduler.py +0 -0
  47. {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1}/src/coprogrammer/scheduler_cli.py +0 -0
  48. {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1}/src/coprogrammer/scheduler_mcp.py +0 -0
  49. {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1}/src/coprogrammer.egg-info/dependency_links.txt +0 -0
  50. {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1}/src/coprogrammer.egg-info/entry_points.txt +0 -0
  51. {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1}/src/coprogrammer.egg-info/top_level.txt +0 -0
  52. {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1}/tests/test_briefing.py +0 -0
  53. {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1}/tests/test_channels.py +0 -0
  54. {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1}/tests/test_cli.py +0 -0
  55. {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1}/tests/test_collaboration.py +0 -0
  56. {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1}/tests/test_collaboration_processes.py +0 -0
  57. {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1}/tests/test_demo.py +0 -0
  58. {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1}/tests/test_integrations.py +0 -0
  59. {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1}/tests/test_knowledge.py +0 -0
  60. {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1}/tests/test_knowledge_mcp.py +0 -0
  61. {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1}/tests/test_manager_performance.py +0 -0
  62. {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1}/tests/test_mcp_robustness.py +0 -0
  63. {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1}/tests/test_plugin_package.py +0 -0
  64. {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1}/tests/test_providers.py +0 -0
  65. {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1}/tests/test_review.py +0 -0
  66. {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1}/tests/test_review_summary.py +0 -0
  67. {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1}/tests/test_review_summary_cli.py +0 -0
  68. {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1}/tests/test_scheduler.py +0 -0
  69. {coprogrammer-0.2.0a2 → coprogrammer-0.3.0a1}/tests/test_scheduling_integration.py +0 -0
  70. {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.2.0a2
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.2.0a2 pre-release.** Install the published package from
61
- > [PyPI](https://pypi.org/project/coprogrammer/). Client configuration
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 this source update
66
-
67
- - **One-command offline demo:** two real Git worktrees exercise claims,
68
- dependency and path guards, message receipt and task completion. No model key.
69
- - **One collaboration briefing:** ready work, blockers, stale windows, open
70
- decisions and next actions, available through both CLI and MCP.
71
- - **Less repeated state replay:** status views reuse validation within a
72
- snapshot, while transactions and standalone history checks stay intact.
73
- See the [reproducible benchmark](docs/upgrade-2026-09-30/performance.md).
74
- - **A complete Chinese entrypoint** and a [six-project research update](docs/research/ecosystem-2026-09-30.md)
75
- explaining the choices behind this increment.
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.2.0a2 pre-release.** Install the published package from
20
- > [PyPI](https://pypi.org/project/coprogrammer/). Client configuration
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 this source update
25
-
26
- - **One-command offline demo:** two real Git worktrees exercise claims,
27
- dependency and path guards, message receipt and task completion. No model key.
28
- - **One collaboration briefing:** ready work, blockers, stale windows, open
29
- decisions and next actions, available through both CLI and MCP.
30
- - **Less repeated state replay:** status views reuse validation within a
31
- snapshot, while transactions and standalone history checks stay intact.
32
- See the [reproducible benchmark](docs/upgrade-2026-09-30/performance.md).
33
- - **A complete Chinese entrypoint** and a [six-project research update](docs/research/ecosystem-2026-09-30.md)
34
- explaining the choices behind this increment.
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.2.0a2"
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"
@@ -2,4 +2,4 @@
2
2
 
3
3
  __all__ = ["__version__"]
4
4
 
5
- __version__ = "0.2.0a1"
5
+ __version__ = "0.3.0a1"
@@ -16,8 +16,9 @@ touch overlapping code or shared contracts.
16
16
 
17
17
  ## Workflow
18
18
 
19
- For cross-window work, use a matching source-preview CLI with `manager session`
20
- and `manager sync` available. Keep one session ID per window, even when several
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
- if os.name == "nt" and handle.tell() == 0:
210
- handle.write(b"\0")
211
- handle.flush()
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
- if current.is_symlink() or current.resolve() != current:
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
- if current.exists():
34
- mode = current.stat().st_mode
35
- if current == target and not stat.S_ISREG(mode):
36
- raise RuntimeError(f"setup target is not a regular file: {relative}")
37
- if current != target and not stat.S_ISDIR(mode):
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"