ww-agentic-workflows 1.0.0.dev3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (167) hide show
  1. ww/__init__.py +18 -0
  2. ww/_bundled_extensions/ww/git/extension.py +1728 -0
  3. ww/action_execution.py +887 -0
  4. ww/actions/__init__.py +94 -0
  5. ww/actions/command.py +444 -0
  6. ww/actions/contracts.py +699 -0
  7. ww/actions/extension.py +197 -0
  8. ww/actions/mcp.py +84 -0
  9. ww/actions/prompt.py +74 -0
  10. ww/actions/skill.py +62 -0
  11. ww/actions/slash_command.py +63 -0
  12. ww/agents.py +151 -0
  13. ww/amendments.py +54 -0
  14. ww/artifacts.py +93 -0
  15. ww/assessments.py +181 -0
  16. ww/assets/__init__.py +2 -0
  17. ww/assets/agent_instructions.md +49 -0
  18. ww/assets/docs/examples.md +879 -0
  19. ww/assets/docs/features.md +4639 -0
  20. ww/assets/docs/specification.md +1876 -0
  21. ww/assets/noww_skill.md +11 -0
  22. ww/assets/workflows/catchall.yaml +26 -0
  23. ww/assets/workflows/onboarding.yaml +586 -0
  24. ww/assets/workflows/scriptize.yaml +130 -0
  25. ww/assets/ww-automate_skill.md +23 -0
  26. ww/assets/ww-deduce-feedback_skill.md +38 -0
  27. ww/assets/ww-feedback-rules_skill.md +48 -0
  28. ww/assets/ww-learn-project_skill.md +22 -0
  29. ww/assets/ww-refresh_skill.md +26 -0
  30. ww/assets/ww-rule_skill.md +83 -0
  31. ww/assets/ww-rules-from-artifacts_skill.md +22 -0
  32. ww/assets/ww-scriptize_skill.md +33 -0
  33. ww/assets/ww-setup_skill.md +94 -0
  34. ww/assets/ww-solve_skill.md +23 -0
  35. ww/assets/ww-suggest_skill.md +32 -0
  36. ww/assets/ww-wizard_skill.md +105 -0
  37. ww/assets/ww_skill.md +59 -0
  38. ww/assignments.py +283 -0
  39. ww/bootstrap.py +405 -0
  40. ww/builtin_workflows.py +215 -0
  41. ww/changes.py +225 -0
  42. ww/child_coordination.py +482 -0
  43. ww/children.py +106 -0
  44. ww/claude_permissions.py +115 -0
  45. ww/cli/__init__.py +7 -0
  46. ww/cli/__main__.py +6 -0
  47. ww/cli/audit.py +129 -0
  48. ww/cli/catalogs.py +131 -0
  49. ww/cli/discover.py +607 -0
  50. ww/cli/initialization.py +898 -0
  51. ww/cli/lookup.py +287 -0
  52. ww/cli/main.py +1768 -0
  53. ww/cli/parser.py +1200 -0
  54. ww/cli/prompts.py +217 -0
  55. ww/cli/updates.py +117 -0
  56. ww/completion_artifacts.py +156 -0
  57. ww/completion_inputs.py +39 -0
  58. ww/config/__init__.py +582 -0
  59. ww/config/actions.py +591 -0
  60. ww/config/composition.py +571 -0
  61. ww/config/rules.py +511 -0
  62. ww/config/steps.py +1220 -0
  63. ww/config/values.py +223 -0
  64. ww/config_files.py +191 -0
  65. ww/config_writes.py +264 -0
  66. ww/contracts.py +155 -0
  67. ww/control.py +41 -0
  68. ww/defaults.py +130 -0
  69. ww/design_docs.py +32 -0
  70. ww/discovery.py +104 -0
  71. ww/documents.py +217 -0
  72. ww/errors.py +18 -0
  73. ww/executable.py +43 -0
  74. ww/execution_models/__init__.py +64 -0
  75. ww/execution_models/construction.py +148 -0
  76. ww/execution_models/decoding.py +38 -0
  77. ww/execution_models/plan_codec.py +565 -0
  78. ww/execution_models/records.py +1206 -0
  79. ww/execution_models/runs.py +266 -0
  80. ww/extensions/__init__.py +40 -0
  81. ww/extensions/api.py +559 -0
  82. ww/extensions/registry.py +864 -0
  83. ww/extensions/store.py +78 -0
  84. ww/feedback.py +342 -0
  85. ww/handler_repairs.py +57 -0
  86. ww/hooks/__init__.py +40 -0
  87. ww/hooks/agents.py +380 -0
  88. ww/hooks/install.py +168 -0
  89. ww/hooks/notices.py +206 -0
  90. ww/hooks/records.py +209 -0
  91. ww/hooks/runtime.py +266 -0
  92. ww/hooks/transcripts.py +183 -0
  93. ww/inspect.py +896 -0
  94. ww/instructions/__init__.py +17 -0
  95. ww/instructions/builder.py +1682 -0
  96. ww/instructions/commands.py +335 -0
  97. ww/instructions/handoff.py +149 -0
  98. ww/instructions/models.py +686 -0
  99. ww/instructions/policy.py +219 -0
  100. ww/instructions/text.py +168 -0
  101. ww/interactions.py +187 -0
  102. ww/interpolation.py +37 -0
  103. ww/item_passes.py +167 -0
  104. ww/items.py +99 -0
  105. ww/locking.py +207 -0
  106. ww/metadata_publication.py +230 -0
  107. ww/onboarding.py +229 -0
  108. ww/open_work.py +236 -0
  109. ww/operations.py +193 -0
  110. ww/operator_ui/__init__.py +16 -0
  111. ww/operator_ui/page.html +351 -0
  112. ww/operator_ui/server.py +215 -0
  113. ww/operator_ui/session.py +389 -0
  114. ww/operator_ui/sheet.py +104 -0
  115. ww/operator_ui/view.py +109 -0
  116. ww/output.py +339 -0
  117. ww/output_adapters/__init__.py +12 -0
  118. ww/output_adapters/base.py +25 -0
  119. ww/output_adapters/json_adapter.py +37 -0
  120. ww/output_adapters/markdown.py +2293 -0
  121. ww/output_adapters/rule_pages.py +337 -0
  122. ww/output_adapters/terminal.py +21 -0
  123. ww/package_updates.py +167 -0
  124. ww/plan/__init__.py +38 -0
  125. ww/plan/actions.py +207 -0
  126. ww/plan/compiler.py +1492 -0
  127. ww/plan/constructs.py +456 -0
  128. ww/plan/models.py +665 -0
  129. ww/project_config.py +752 -0
  130. ww/recovery.py +401 -0
  131. ww/replanning.py +367 -0
  132. ww/results.py +77 -0
  133. ww/rule_checks.py +230 -0
  134. ww/rule_conversion.py +331 -0
  135. ww/rule_disputes.py +148 -0
  136. ww/rule_store.py +456 -0
  137. ww/rule_verification.py +714 -0
  138. ww/rule_views.py +447 -0
  139. ww/rule_writes.py +920 -0
  140. ww/run_coordination.py +158 -0
  141. ww/runtimes.py +105 -0
  142. ww/service.py +4405 -0
  143. ww/setup_apply.py +428 -0
  144. ww/step_values.py +20 -0
  145. ww/storage.py +447 -0
  146. ww/storage_adapters/__init__.py +36 -0
  147. ww/storage_adapters/base.py +540 -0
  148. ww/storage_adapters/filesystem.py +370 -0
  149. ww/storage_adapters/memory.py +195 -0
  150. ww/storage_adapters/project_metadata.py +69 -0
  151. ww/storage_adapters/task_document.py +484 -0
  152. ww/task_ids.py +114 -0
  153. ww/task_references.py +124 -0
  154. ww/transitions.py +1619 -0
  155. ww/updates.py +399 -0
  156. ww/upgrade.py +95 -0
  157. ww/validation.py +168 -0
  158. ww/variables.py +275 -0
  159. ww/workflow_config.py +854 -0
  160. ww/workflow_update.py +239 -0
  161. ww/workflow_validation.py +1260 -0
  162. ww/workspace.py +50 -0
  163. ww_agentic_workflows-1.0.0.dev3.dist-info/METADATA +690 -0
  164. ww_agentic_workflows-1.0.0.dev3.dist-info/RECORD +167 -0
  165. ww_agentic_workflows-1.0.0.dev3.dist-info/WHEEL +4 -0
  166. ww_agentic_workflows-1.0.0.dev3.dist-info/entry_points.txt +2 -0
  167. ww_agentic_workflows-1.0.0.dev3.dist-info/licenses/LICENSE +674 -0
ww/contracts.py ADDED
@@ -0,0 +1,155 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ """Closed-set type contracts shared by plans, execution, and presentation."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from typing import Literal
7
+
8
+ # One condition of a command's ``assert`` list.
9
+ AssertionKind = Literal["empty", "equals"]
10
+ RequestedActionKind = Literal["mcp", "prompt", "skill", "slash_command"]
11
+ HookPhase = Literal[
12
+ "before_start_workflow",
13
+ "before_start",
14
+ "before_complete",
15
+ "after_complete",
16
+ "before_complete_workflow",
17
+ ]
18
+ PlanItemPhase = Literal[
19
+ "before_start_workflow",
20
+ "before_start",
21
+ "step",
22
+ "before_complete",
23
+ "after_complete",
24
+ "before_complete_workflow",
25
+ ]
26
+ HookScope = Literal["global", "workflow", "step"]
27
+ # What a failed ``before_complete`` hook does: stop for the operator, as any
28
+ # failed handler does, or send the step back to its worker to fix.
29
+ HookFailure = Literal["fix", "operator"]
30
+ # The outcome of one check ww ran when a step completed.
31
+ CheckStatus = Literal["passed", "failed", "not_applicable"]
32
+ # Where a check came from: a rule's own command, a ``fix`` hook, a command
33
+ # the operator approved into the rule-automation store, or a verifier's
34
+ # verdict on a rule without a command.
35
+ CheckSource = Literal["rule", "hook", "derived", "judged"]
36
+ # Why a failed run failed, when the reason is not the item's own error.
37
+ FailureKind = Literal[
38
+ "fix_limit", "check_disputed", "value_unavailable", "work_failed", "pass_incomplete"
39
+ ]
40
+ # A rule's standing in the rule-automation store, keyed by its text hash.
41
+ RuleAutomationStatus = Literal[
42
+ "approach_proposed",
43
+ "approach_approved",
44
+ "interpreted",
45
+ "proposed",
46
+ "converted",
47
+ "rejected",
48
+ "not_convertible",
49
+ "ambiguous",
50
+ ]
51
+ # A derived check's standing in the rule-automation store.
52
+ CheckAutomationStatus = Literal["proposed", "converted", "rejected"]
53
+ # How a step's rule without a command of its own is enforced, decided when
54
+ # the step begins: by a converted derived check, or by a verifier's verdict.
55
+ RuleResolutionStatus = Literal["converted", "judged"]
56
+ Verdict = Literal["pass", "fail"]
57
+ ItemOperation = Literal[
58
+ "collect", "process_item", "resolve_item", "report_item", "handle_item"
59
+ ]
60
+ # ``handle_item`` is the built-in stage of a bare ``items`` step; it cannot be
61
+ # declared on a configured step.
62
+ # How the per-item stages of an ``items`` step are split into worker
63
+ # assignments in the ``auto`` runtime.
64
+ ItemAssignment = Literal["together", "per_item", "per_step"]
65
+ # How the body steps of a ``loop`` are split into worker assignments in the
66
+ # ``auto`` runtime: one assignment per body step, or one per loop round for
67
+ # consecutive steps that resolve to the same worker settings.
68
+ LoopAssignment = Literal["per_round", "per_step"]
69
+ DEFAULT_LOOP_ASSIGNMENT: LoopAssignment = "per_round"
70
+ ChildOperation = Literal["collect"]
71
+
72
+ # Action identifiers are registry keys; third-party internal registrations may
73
+ # extend the built-in set without changing generic plan consumers.
74
+ PlanItemKind = str
75
+ # Task IDs issued while an external identity is still being bound.
76
+ BOOTSTRAP_REQUEST_PREFIX = "REQUEST-"
77
+ PlanItemOwner = Literal["agent", "ww"]
78
+ ExecutionKind = Literal[
79
+ "agent_instruction", "automatic", "loop_control", "workflow_transition"
80
+ ]
81
+ LoopOperation = Literal["enter", "repeat"]
82
+
83
+ CommandStatus = Literal["pending", "in_progress", "interrupted", "completed", "failed"]
84
+ ItemStatus = Literal[
85
+ "pending",
86
+ "in_progress",
87
+ "interrupted",
88
+ "awaiting_input",
89
+ "completed",
90
+ "failed",
91
+ ]
92
+ ExecutionStatus = Literal[
93
+ "pending",
94
+ "in_progress",
95
+ "awaiting_input",
96
+ "interrupted",
97
+ "failed",
98
+ "completed",
99
+ # Closed by a later start of the same workflow; kept as history.
100
+ "abandoned",
101
+ ]
102
+ # A run that is neither finished nor abandoned is the task's open run: it
103
+ # blocks another start and is the one every task command addresses.
104
+ CLOSED_RUN_STATUSES: frozenset[str] = frozenset({"completed", "abandoned"})
105
+
106
+
107
+ def run_is_open(status: str) -> bool:
108
+ return status not in CLOSED_RUN_STATUSES
109
+
110
+
111
+ StepStatus = Literal["pending", "in_progress", "interrupted", "completed", "failed"]
112
+ # ``skipped``: a ``break`` on a per-child parent stage ended the children
113
+ # before this one started.
114
+ ChildStatus = Literal[
115
+ "pending", "starting", "in_progress", "completed", "failed", "skipped"
116
+ ]
117
+ RunStatus = Literal["pending", "in_progress", "completed", "failed"]
118
+ InstructionStatus = Literal[
119
+ "pending",
120
+ "in_progress",
121
+ "awaiting_input",
122
+ "interrupted",
123
+ "failed",
124
+ "completed",
125
+ "abandoned",
126
+ "task_summary",
127
+ ]
128
+ RecoveryAction = Literal["retry", "force"]
129
+ CallerRole = Literal["manager", "worker"]
130
+ # Who performs an agent step: the manager in its own session, or a worker it
131
+ # delegates to. Both are also the caller roles.
132
+ StepRole = Literal["manager", "worker"]
133
+ # Who acts next. The operator is the human ww waits for; never a caller role.
134
+ NextRole = Literal["manager", "worker", "operator"]
135
+ Control = Literal["continue_worker", "handoff_manager", "blocked", "awaiting_operator"]
136
+ # Why a task waits for the operator.
137
+ OperatorReason = Literal[
138
+ "handler_failed",
139
+ "work_failed",
140
+ "child_failed",
141
+ "handler_interrupted",
142
+ "loop_limit",
143
+ "fix_limit",
144
+ "check_disputed",
145
+ "value_unavailable",
146
+ "pass_incomplete",
147
+ "plan_changed",
148
+ ]
149
+ CALLER_ROLES: tuple[CallerRole, ...] = ("manager", "worker")
150
+ CONTROL_VALUES: tuple[Control, ...] = (
151
+ "continue_worker",
152
+ "handoff_manager",
153
+ "blocked",
154
+ "awaiting_operator",
155
+ )
ww/control.py ADDED
@@ -0,0 +1,41 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ """Core-owned workflow-control inspection helpers."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from typing import TYPE_CHECKING
7
+
8
+ from ww.actions import PlannedAction, actions
9
+ from ww.operations import ChildWorkflowRun, LoopBoundary, WorkflowHandoff
10
+
11
+ if TYPE_CHECKING:
12
+ from ww.plan import PlanItem
13
+
14
+
15
+ def workflow_transition(item: PlanItem) -> WorkflowHandoff | None:
16
+ return item.operation if isinstance(item.operation, WorkflowHandoff) else None
17
+
18
+
19
+ def child_workflow(item: PlanItem) -> ChildWorkflowRun | None:
20
+ return item.operation if isinstance(item.operation, ChildWorkflowRun) else None
21
+
22
+
23
+ def loop_control(item: PlanItem) -> LoopBoundary | None:
24
+ return item.operation if isinstance(item.operation, LoopBoundary) else None
25
+
26
+
27
+ def is_coordinator(item: PlanItem) -> bool:
28
+ return isinstance(item.operation, (WorkflowHandoff, ChildWorkflowRun, LoopBoundary))
29
+
30
+
31
+ def replays_harmlessly(item: PlanItem) -> bool:
32
+ """Whether the item's handler declared that running it again does no damage.
33
+
34
+ ``idempotent: true`` is the author's statement, so an interrupted run of
35
+ such a handler is replayed without asking the operator.
36
+ """
37
+ if not isinstance(item.operation, PlannedAction) or not actions.contains(item.kind):
38
+ return False
39
+ implementation = actions.get(item.kind)
40
+ planned = item.payload_as(implementation.planned_type)
41
+ return implementation.traits(planned).idempotent
ww/defaults.py ADDED
@@ -0,0 +1,130 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ """Built-in project defaults created by ``ww-agentic-workflows init``."""
3
+
4
+ import json
5
+ from copy import deepcopy
6
+ from importlib.resources import files
7
+ from typing import Any
8
+
9
+ from ww.config_files import (
10
+ FILE_STEM,
11
+ LOCAL_SETTINGS_FILE,
12
+ SETTINGS_FILE,
13
+ USER_DIR_VARIABLE,
14
+ )
15
+ from ww.executable import DEFAULT_EXECUTABLE
16
+ from ww.project_config import (
17
+ BUILTIN_DEFAULTS,
18
+ AgentHooks,
19
+ Limits,
20
+ )
21
+ from ww.runtimes import DEFAULT_RUNTIME
22
+
23
+ DEFAULT_WORKFLOWS_YAML = """modes: []
24
+ handlers: []
25
+ hooks: {}
26
+ workflows: []
27
+ """
28
+
29
+
30
+ def default_settings() -> dict[str, Any]:
31
+ """Every root-level setting with its default, in the order init writes them.
32
+
33
+ The settings file init creates holds all of them, so each option can be
34
+ found and changed in place.
35
+ """
36
+ return {
37
+ "enabled": True,
38
+ "runtime": DEFAULT_RUNTIME,
39
+ "update_check": True,
40
+ "feedback_learning": True,
41
+ "executable": DEFAULT_EXECUTABLE,
42
+ "task_format": "TASK-{{uuid}}",
43
+ "limits": Limits().to_dict(),
44
+ "agent_hooks": AgentHooks().to_dict(),
45
+ "rules": {},
46
+ "builtins": deepcopy(BUILTIN_DEFAULTS),
47
+ "workflows": {},
48
+ "projects": [],
49
+ "extensions": {},
50
+ }
51
+
52
+
53
+ DEFAULT_PROJECT_CONFIG_JSON = json.dumps(default_settings(), indent=2) + "\n"
54
+
55
+
56
+ # ``./ww`` runs the binary the settings levels name in ``executable``, read on
57
+ # every run so a project switches installs by editing one line; the local file
58
+ # wins over the repo one, which wins over the user's. Without python3 or the
59
+ # key it runs the standard name.
60
+ PROJECT_LAUNCHER = f"""#!/bin/sh
61
+ set -eu
62
+ project_root=$(CDPATH= cd "$(dirname "$0")" && pwd)
63
+ cd "$project_root"
64
+ executable={DEFAULT_EXECUTABLE}
65
+ if command -v python3 >/dev/null 2>&1; then
66
+ configured=$(python3 -c '
67
+ import json, os
68
+ user = os.environ.get("{USER_DIR_VARIABLE}") or os.path.join(
69
+ os.environ.get("XDG_CONFIG_HOME") or os.path.expanduser("~/.config"),
70
+ "{FILE_STEM}",
71
+ )
72
+ value = None
73
+ for path in (
74
+ os.path.join(user, "{SETTINGS_FILE}"),
75
+ "{SETTINGS_FILE}",
76
+ "{LOCAL_SETTINGS_FILE}",
77
+ ):
78
+ try:
79
+ found = json.load(open(path)).get("executable")
80
+ except (OSError, ValueError, AttributeError):
81
+ continue
82
+ if isinstance(found, str) and found.strip():
83
+ value = found.strip()
84
+ print(value or "")
85
+ ' 2>/dev/null || true)
86
+ if [ -n "$configured" ]; then
87
+ executable=$configured
88
+ fi
89
+ fi
90
+ exec "$executable" "$@"
91
+ """
92
+
93
+
94
+ AGENT_INSTRUCTIONS = (
95
+ files("ww.assets").joinpath("agent_instructions.md").read_text(encoding="utf-8")
96
+ )
97
+ # The skills ``init`` offers to install into each agent directory, by name:
98
+ # ``ww`` to work through ww, ``noww`` for the operator to opt out of it,
99
+ # ``ww-rule`` to write rules for ww's steps from the operator's words, and
100
+ # ``ww-setup`` with the skills it guides through, each starting one of ww's
101
+ # learning and setup workflows (``ww-refresh`` reruns the project learning;
102
+ # express setup is ``ww-suggest`` told to derive defaults, with no skill of
103
+ # its own), ``ww-scriptize``, which starts ``ww-scriptize-rules``, and
104
+ # ``ww-wizard``, which shapes workflows and rules with the operator through
105
+ # ``setup apply``/``setup update`` and the rules skills.
106
+ WW_SKILL_NAME = "ww"
107
+ SKILLS = {
108
+ name: files("ww.assets").joinpath(f"{name}_skill.md").read_text(encoding="utf-8")
109
+ for name in (
110
+ WW_SKILL_NAME,
111
+ "noww",
112
+ "ww-rule",
113
+ "ww-setup",
114
+ "ww-learn-project",
115
+ "ww-suggest",
116
+ "ww-refresh",
117
+ "ww-solve",
118
+ "ww-rules-from-artifacts",
119
+ "ww-feedback-rules",
120
+ "ww-deduce-feedback",
121
+ "ww-automate",
122
+ "ww-scriptize",
123
+ "ww-wizard",
124
+ )
125
+ }
126
+
127
+
128
+ def skill_location(directory: str, name: str) -> str:
129
+ """Where a skill lives inside an agent directory."""
130
+ return f"{directory}/skills/{name}/SKILL.md"
ww/design_docs.py ADDED
@@ -0,0 +1,32 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ """The three design documents, readable from a checkout or an installation.
3
+
4
+ The canonical files live in ``documentation/``. A built distribution carries
5
+ byte-for-byte copies under ``ww/assets/docs`` (added by the build, never
6
+ committed), so ``ww docs`` shows the documents of the installed version.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from importlib.resources import files
12
+ from pathlib import Path
13
+
14
+ from .errors import StateError
15
+
16
+ DESIGN_DOCUMENTS = ("specification", "features", "examples")
17
+ PACKAGED_DIRECTORY = "docs"
18
+ _CHECKOUT = Path(__file__).resolve().parents[2] / "documentation"
19
+
20
+
21
+ def read_design_document(name: str) -> str:
22
+ """Return the same-version text of one design document."""
23
+ if name not in DESIGN_DOCUMENTS:
24
+ known = ", ".join(DESIGN_DOCUMENTS)
25
+ raise StateError(f"unknown document {name!r}; choose one of: {known}")
26
+ packaged = files("ww.assets").joinpath(PACKAGED_DIRECTORY).joinpath(f"{name}.md")
27
+ if packaged.is_file():
28
+ return packaged.read_text(encoding="utf-8")
29
+ checkout = _CHECKOUT / f"{name}.md"
30
+ if checkout.is_file():
31
+ return checkout.read_text(encoding="utf-8")
32
+ raise StateError(f"the {name} document is not available in this installation")
ww/discovery.py ADDED
@@ -0,0 +1,104 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ """Local discovery of agent skills and commands."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import re
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+
10
+ from ww.errors import ConfigurationError
11
+
12
+ AGENT_DIRECTORIES = {
13
+ "codex": ".codex",
14
+ "claudecode": ".claude",
15
+ "gemini": ".gemini",
16
+ "antigravity": ".antigravity",
17
+ "deepseek": ".deepseek",
18
+ "kimi": ".kimi",
19
+ "cursor": ".cursor",
20
+ "grok": ".grok",
21
+ }
22
+ CUSTOM_AGENT_PREFIX = "custom:"
23
+ # A custom agent name, e.g. "my-agent.v2"; "-agent" does not match.
24
+ _AGENT_NAME = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]*")
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class AvailableActions:
29
+ """Project-local action names available to one agent integration."""
30
+
31
+ skills: frozenset[str]
32
+ slash_commands: frozenset[str]
33
+
34
+
35
+ class AgentDiscovery:
36
+ """Discover skills and commands in project-local agent directories."""
37
+
38
+ def __init__(self, root: Path) -> None:
39
+ self.root = root
40
+
41
+ def available(self, agent: str) -> AvailableActions:
42
+ """Return normalized skill and slash-command names for ``agent``."""
43
+ skills, commands = self._discover(self._roots(normalize_agent(agent)))
44
+ return AvailableActions(
45
+ frozenset(item.removeprefix("/") for item in skills), frozenset(commands)
46
+ )
47
+
48
+ def profile(self, agent: str, name: str) -> Path | None:
49
+ """Return a project-local profile from the selected agent's directory."""
50
+ normalized = normalize_agent(agent)
51
+ directory = AGENT_DIRECTORIES.get(normalized, f".{normalized}")
52
+ profiles = self.root / directory / "agents"
53
+ if not profiles.is_dir():
54
+ return None
55
+ return next(
56
+ (
57
+ path
58
+ for path in profiles.rglob("*")
59
+ if path.is_file() and path.stem == name
60
+ ),
61
+ None,
62
+ )
63
+
64
+ def _roots(self, agent: str) -> tuple[Path, ...]:
65
+ directory = AGENT_DIRECTORIES.get(agent)
66
+ if directory is None:
67
+ if not _AGENT_NAME.fullmatch(agent):
68
+ raise ConfigurationError(f"invalid custom agent name {agent!r}")
69
+ directory = f".{agent}"
70
+ agent_root = self.root / directory
71
+ return (self.root / ".agents", agent_root)
72
+
73
+ @staticmethod
74
+ def _discover(roots: tuple[Path, ...]) -> tuple[set[str], set[str]]:
75
+ skills: set[str] = set()
76
+ commands: set[str] = set()
77
+ for root in roots:
78
+ if not root.is_dir():
79
+ continue
80
+ for path in root.rglob("SKILL.md"):
81
+ if "skills" in path.parts:
82
+ skills.add(f"/{path.parent.name}")
83
+ for path in root.rglob("*.md"):
84
+ if "commands" in path.parts:
85
+ commands.add(path.stem)
86
+ return skills, commands
87
+
88
+
89
+ def normalize_agent(agent: str) -> str:
90
+ """Validate a public agent argument and strip an explicit custom prefix."""
91
+ if agent in AGENT_DIRECTORIES:
92
+ return agent
93
+ if agent.startswith(CUSTOM_AGENT_PREFIX):
94
+ custom_name = agent.removeprefix(CUSTOM_AGENT_PREFIX)
95
+ if _AGENT_NAME.fullmatch(custom_name):
96
+ return custom_name
97
+ raise ConfigurationError(
98
+ "custom agent name must contain letters, digits, ., _, or -"
99
+ )
100
+ supported = ", ".join(AGENT_DIRECTORIES)
101
+ raise ConfigurationError(
102
+ f"unsupported agent {agent!r}; choose one of: {supported}, "
103
+ f"or use {CUSTOM_AGENT_PREFIX}<name>"
104
+ )
ww/documents.py ADDED
@@ -0,0 +1,217 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ """Durable documents: free-format files workflows read and update across runs.
3
+
4
+ A document is declared once at the root of ``ww.yaml`` and lives in
5
+ the task directory, under ``.ww`` for the project scope, or in the user
6
+ configuration directory for the user scope, where every project of the user
7
+ shares it. Agents edit the file in place; ww only resolves its path, checks
8
+ that a step which promised an update left the file behind, and journals who
9
+ updated it last. The journal of a user document is the project's: it records
10
+ what this project's runs did to the shared file.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import hashlib
16
+ import json
17
+ import shutil
18
+ from dataclasses import dataclass
19
+ from pathlib import Path
20
+
21
+ from ww.config_files import user_directory
22
+ from ww.errors import StateError
23
+ from ww.storage import Storage
24
+ from ww.validation import expect_optional_string, expect_string
25
+ from ww.workflow_config import TASK_ID_TOKEN, DocumentDefinition
26
+
27
+ JOURNAL_FILE = "documents.json"
28
+ DOCUMENTS_DIRECTORY = "documents"
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class DocumentUpdateRecord:
33
+ """Who last updated a document, and the content they left."""
34
+
35
+ updated_at: str
36
+ run_id: str | None
37
+ step: str
38
+ sha256: str
39
+
40
+ def to_dict(self) -> dict[str, str | None]:
41
+ return {
42
+ "updated_at": self.updated_at,
43
+ "run_id": self.run_id,
44
+ "step": self.step,
45
+ "sha256": self.sha256,
46
+ }
47
+
48
+ @classmethod
49
+ def from_dict(cls, data: object, label: str) -> DocumentUpdateRecord:
50
+ if not isinstance(data, dict):
51
+ raise ValueError(f"{label} must be a mapping")
52
+ return cls(
53
+ expect_string(data.get("updated_at"), f"{label}.updated_at"),
54
+ expect_optional_string(data.get("run_id"), f"{label}.run_id"),
55
+ expect_string(data.get("step"), f"{label}.step"),
56
+ expect_string(data.get("sha256"), f"{label}.sha256"),
57
+ )
58
+
59
+
60
+ class DocumentStore:
61
+ """Paths and the update journal for declared documents."""
62
+
63
+ def __init__(self, storage: Storage) -> None:
64
+ self.storage = storage
65
+
66
+ def path(
67
+ self,
68
+ document: DocumentDefinition,
69
+ task_id: str | None,
70
+ workspace: Path | None = None,
71
+ ) -> Path:
72
+ """The document's absolute file for the filesystem ww runs in now.
73
+
74
+ A declared ``path`` is relative to the project root, or to the task's
75
+ working directory for a task document when the run has one, so a file
76
+ kept in the repository lands on the task's branch. A user document
77
+ lives in the user configuration directory, which is created for it.
78
+ """
79
+ if document.scope == "user":
80
+ return self._user_path(document)
81
+ if document.path is None:
82
+ directory = (
83
+ self.storage.runtime_path / DOCUMENTS_DIRECTORY
84
+ if document.scope == "project"
85
+ else self._task_directory(task_id) / DOCUMENTS_DIRECTORY
86
+ )
87
+ return (directory / f"{document.name}.md").resolve()
88
+ if document.scope == "task" and task_id is None:
89
+ raise StateError("a task-scoped document needs a task ID")
90
+ base = (
91
+ workspace
92
+ if document.scope == "task" and workspace is not None
93
+ else self.storage.root
94
+ )
95
+ relative = document.path.replace(TASK_ID_TOKEN, task_id or "")
96
+ return (base / relative).resolve()
97
+
98
+ @staticmethod
99
+ def _user_path(document: DocumentDefinition) -> Path:
100
+ directory = user_directory().resolve()
101
+ path = (directory / (document.path or f"{document.name}.md")).resolve()
102
+ if not path.is_relative_to(directory):
103
+ raise StateError(
104
+ f"document {document.name!r} resolves outside the user "
105
+ f"configuration directory: {path}"
106
+ )
107
+ path.parent.mkdir(parents=True, exist_ok=True)
108
+ return path
109
+
110
+ def exists(
111
+ self,
112
+ document: DocumentDefinition,
113
+ task_id: str | None,
114
+ workspace: Path | None = None,
115
+ ) -> bool:
116
+ return self.path(document, task_id, workspace).is_file()
117
+
118
+ def record_update(
119
+ self,
120
+ document: DocumentDefinition,
121
+ task_id: str | None,
122
+ *,
123
+ run_id: str | None,
124
+ step: str,
125
+ updated_at: str,
126
+ workspace: Path | None = None,
127
+ ) -> DocumentUpdateRecord:
128
+ """Journal that ``step`` updated the document, hashing its content."""
129
+ path = self.path(document, task_id, workspace)
130
+ if not path.is_file():
131
+ raise StateError(f"document {document.name!r} does not exist at {path}")
132
+ record = DocumentUpdateRecord(
133
+ updated_at, run_id, step, hashlib.sha256(path.read_bytes()).hexdigest()
134
+ )
135
+ journal_path = self._journal_path(document, task_id)
136
+ # A task journal is already serialized by the task lock; a project
137
+ # journal is shared by every task, so its read-modify-write needs a
138
+ # lock of its own, as project metadata has.
139
+ with self.storage.locks.lock(journal_path, purpose="document journal"):
140
+ journal = self._read_journal(journal_path)
141
+ journal[document.name] = record.to_dict()
142
+ self.storage.locks.atomic_write(
143
+ journal_path, json.dumps(journal, indent=2, sort_keys=True) + "\n"
144
+ )
145
+ return record
146
+
147
+ def last_update(
148
+ self, document: DocumentDefinition, task_id: str | None
149
+ ) -> DocumentUpdateRecord | None:
150
+ journal_path = self._journal_path(document, task_id)
151
+ entry = self._read_journal(journal_path).get(document.name)
152
+ if entry is None:
153
+ return None
154
+ try:
155
+ return DocumentUpdateRecord.from_dict(entry, document.name)
156
+ except ValueError as error:
157
+ raise StateError(
158
+ f"invalid document journal {journal_path}: {error}"
159
+ ) from error
160
+
161
+ def listing(
162
+ self,
163
+ documents: tuple[DocumentDefinition, ...],
164
+ task_id: str | None,
165
+ workspace: Path | None = None,
166
+ ) -> list[dict[str, object]]:
167
+ """Describe every declared document: where it is and who updated it."""
168
+ result: list[dict[str, object]] = []
169
+ for document in documents:
170
+ if document.scope == "task" and task_id is None:
171
+ continue
172
+ last = self.last_update(document, task_id)
173
+ result.append(
174
+ {
175
+ "name": document.name,
176
+ "description": document.description,
177
+ "scope": document.scope,
178
+ "path": str(self.path(document, task_id, workspace)),
179
+ "exists": self.exists(document, task_id, workspace),
180
+ "last_update": last.to_dict() if last else None,
181
+ }
182
+ )
183
+ return result
184
+
185
+ def remove_task(self, task_id: str) -> None:
186
+ """Forget a task's documents: its journal and the files kept under ``.ww``.
187
+
188
+ A document declared with an explicit ``path`` is the workflow's own
189
+ file and stays in place.
190
+ """
191
+ directory = self._task_directory(task_id)
192
+ (directory / JOURNAL_FILE).unlink(missing_ok=True)
193
+ documents = directory / DOCUMENTS_DIRECTORY
194
+ if documents.is_dir() and not documents.is_symlink():
195
+ shutil.rmtree(documents)
196
+
197
+ def _journal_path(self, document: DocumentDefinition, task_id: str | None) -> Path:
198
+ if document.scope != "task":
199
+ return self.storage.runtime_path / JOURNAL_FILE
200
+ return self._task_directory(task_id) / JOURNAL_FILE
201
+
202
+ def _task_directory(self, task_id: str | None) -> Path:
203
+ if task_id is None:
204
+ raise StateError("a task-scoped document needs a task ID")
205
+ return self.storage.runtime_path / "tasks" / task_id
206
+
207
+ @staticmethod
208
+ def _read_journal(path: Path) -> dict[str, object]:
209
+ if not path.is_file():
210
+ return {}
211
+ try:
212
+ data = json.loads(path.read_text(encoding="utf-8"))
213
+ except (OSError, json.JSONDecodeError) as error:
214
+ raise StateError(f"invalid document journal {path}: {error}") from error
215
+ if not isinstance(data, dict):
216
+ raise StateError(f"invalid document journal {path}: not a mapping")
217
+ return data