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
@@ -0,0 +1,230 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ """Durable task and project metadata publication.
3
+
4
+ A completing item declares metadata values. They are validated while the item
5
+ is still in progress, recorded on the run as an intent together with the
6
+ completion, and only then projected into the metadata stores. A crash between
7
+ the commit and the projection leaves a retriable intent rather than a metadata
8
+ value that claims a completion which did not happen.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from dataclasses import replace
14
+
15
+ from ww.errors import StateError
16
+ from ww.execution_models import (
17
+ ExecutionState,
18
+ PlanSnapshot,
19
+ ProjectMetadataPublication,
20
+ )
21
+ from ww.plan import PlanItem
22
+ from ww.run_coordination import RunLifecycle
23
+ from ww.storage_adapters import (
24
+ ProjectMetadata,
25
+ ProjectMetadataStorage,
26
+ TaskMetadata,
27
+ TaskStorageAdapter,
28
+ )
29
+ from ww.storage_adapters.base import MetadataLeaf, append_metadata_leaf
30
+ from ww.workflow_config import SavedMetadata
31
+
32
+
33
+ class MetadataPublisher:
34
+ def __init__(
35
+ self,
36
+ tasks: TaskStorageAdapter,
37
+ project_store: ProjectMetadataStorage,
38
+ lifecycle: RunLifecycle,
39
+ ) -> None:
40
+ self.tasks = tasks
41
+ self.project_store = project_store
42
+ self.lifecycle = lifecycle
43
+
44
+ def values(self, task_id: str) -> dict[str, str]:
45
+ """Return task and project metadata as interpolation values."""
46
+ task = self.tasks.read_task_metadata(task_id)
47
+ project = self.project_store.read_project_metadata()
48
+ return {
49
+ **(task.interpolation_values if task is not None else {}),
50
+ **(project.interpolation_values if project is not None else {}),
51
+ }
52
+
53
+ def prepare(
54
+ self,
55
+ task_id: str,
56
+ state: ExecutionState,
57
+ item: PlanItem,
58
+ task_metadata: dict[str, MetadataLeaf],
59
+ project_metadata: dict[str, MetadataLeaf],
60
+ ) -> tuple[TaskMetadata | None, ProjectMetadataPublication | None]:
61
+ """Validate metadata and build the durable intents for one completion."""
62
+ updated_task_metadata = None
63
+ if task_metadata:
64
+ current = self.tasks.read_task_metadata(task_id) or TaskMetadata(task_id)
65
+ merged = _merge_metadata(dict(current.values), task_metadata)
66
+ try:
67
+ updated_task_metadata = TaskMetadata(task_id, tuple(merged.items()))
68
+ except ValueError as error:
69
+ raise StateError(str(error)) from error
70
+ if not project_metadata:
71
+ return updated_task_metadata, None
72
+ # This snapshot is a conflict precondition, not a lock held across the
73
+ # task commit. Holding the project lock would reintroduce an
74
+ # early-publication window and block unrelated producers.
75
+ current_project = (
76
+ self.project_store.read_project_metadata() or ProjectMetadata()
77
+ )
78
+ project_values = tuple(project_metadata.items())
79
+ try:
80
+ # Reject an invalid shape while the producing item is still in
81
+ # progress; malformed input must not become a durable intent that
82
+ # no normal completion can correct.
83
+ ProjectMetadata(
84
+ tuple(
85
+ _merge_metadata(
86
+ dict(current_project.values), project_metadata
87
+ ).items()
88
+ )
89
+ )
90
+ except ValueError as error:
91
+ raise StateError(str(error)) from error
92
+ existing_project = dict(current_project.values)
93
+ return (
94
+ updated_task_metadata,
95
+ ProjectMetadataPublication(
96
+ state.item_executions[state.cursor].operation_id or item.id,
97
+ project_values,
98
+ tuple(
99
+ # An append key merges onto whatever exists at publication
100
+ # time, so it records no snapshot to compare against.
101
+ (
102
+ key,
103
+ None
104
+ if isinstance(value, tuple)
105
+ else _scalar(existing_project.get(key)),
106
+ )
107
+ for key, value in project_values
108
+ ),
109
+ ),
110
+ )
111
+
112
+ def reconcile(
113
+ self, state: ExecutionState, snapshot: PlanSnapshot
114
+ ) -> tuple[ExecutionState, PlanSnapshot]:
115
+ """Project committed metadata intents without exposing uncommitted data."""
116
+ if state.pending_task_metadata:
117
+ metadata = TaskMetadata(state.task_id, state.pending_task_metadata)
118
+ self.tasks.write_task_metadata(metadata)
119
+ state = replace(state, pending_task_metadata=())
120
+ self.lifecycle.commit(state, snapshot)
121
+
122
+ publication = state.pending_project_metadata
123
+ if publication is None:
124
+ return state, snapshot
125
+ with self.project_store.lock_project_metadata():
126
+ current = self.project_store.read_project_metadata() or ProjectMetadata()
127
+ existing = dict(current.values)
128
+ expected = dict(publication.expected_values)
129
+ supplied = dict(publication.values)
130
+ desired = {
131
+ key: (
132
+ append_metadata_leaf(existing.get(key), value)
133
+ if isinstance(value, tuple)
134
+ else value
135
+ )
136
+ for key, value in supplied.items()
137
+ }
138
+ # An append key merges onto whatever is there, so it cannot conflict.
139
+ conflicts = [
140
+ key
141
+ for key, value in desired.items()
142
+ if not isinstance(supplied[key], tuple)
143
+ and existing.get(key) != value
144
+ and existing.get(key) != expected[key]
145
+ ]
146
+ if conflicts:
147
+ raise StateError(
148
+ "project metadata publication conflict for operation "
149
+ f"{publication.operation_id}: " + ", ".join(sorted(conflicts))
150
+ )
151
+ if any(existing.get(key) != value for key, value in desired.items()):
152
+ try:
153
+ self.project_store.write_project_metadata(
154
+ ProjectMetadata(tuple({**existing, **desired}.items()))
155
+ )
156
+ except ValueError as error:
157
+ # The source operation is already durable, so a shape
158
+ # change made by another producer cannot be rolled back.
159
+ # Keep the intent for a retry after the conflicting
160
+ # project metadata has been resolved.
161
+ raise StateError(
162
+ "project metadata publication conflict for operation "
163
+ f"{publication.operation_id}: incompatible metadata shape; "
164
+ "resolve the conflicting project metadata and retry"
165
+ ) from error
166
+ # A crash after the write but before this commit is harmless: the next
167
+ # reconciliation recognizes the desired values and only clears intent.
168
+ state = replace(state, pending_project_metadata=None)
169
+ self.lifecycle.commit(state, snapshot)
170
+ return state, snapshot
171
+
172
+
173
+ def validate_metadata_values(
174
+ values: dict[str, tuple[str, ...]], requested: tuple[SavedMetadata, ...]
175
+ ) -> tuple[dict[str, MetadataLeaf], dict[str, MetadataLeaf]]:
176
+ """Split supplied metadata by scope after checking it against the request.
177
+
178
+ A scalar key takes exactly one value. An ``append`` key may be omitted
179
+ or repeated; its values are appended to the stored list on publication.
180
+ """
181
+ by_name = {item.name: item for item in requested}
182
+ required = {name for name, item in by_name.items() if not item.append}
183
+ unknown, missing = set(values) - set(by_name), required - set(values)
184
+ scopes = {item.scope for item in requested}
185
+ if not scopes:
186
+ label = "task metadata"
187
+ elif len(scopes) == 1:
188
+ label = f"{next(iter(scopes))} metadata"
189
+ else:
190
+ label = "metadata"
191
+ if unknown:
192
+ raise StateError(f"unexpected {label} value(s): " + ", ".join(sorted(unknown)))
193
+ if missing:
194
+ raise StateError(
195
+ f"missing required {label} value(s): " + ", ".join(sorted(missing))
196
+ )
197
+ repeated = sorted(
198
+ name
199
+ for name, supplied in values.items()
200
+ if len(supplied) > 1 and name in required
201
+ )
202
+ if repeated:
203
+ raise StateError(
204
+ f"{label} value(s) supplied more than once: " + ", ".join(repeated)
205
+ )
206
+ task: dict[str, MetadataLeaf] = {}
207
+ project: dict[str, MetadataLeaf] = {}
208
+ for name, supplied in values.items():
209
+ declared = by_name[name]
210
+ target = project if declared.scope == "project" else task
211
+ target[declared.key] = supplied if declared.append else supplied[0]
212
+ return task, project
213
+
214
+
215
+ def _merge_metadata(
216
+ current: dict[str, MetadataLeaf], supplied: dict[str, MetadataLeaf]
217
+ ) -> dict[str, MetadataLeaf]:
218
+ """Replace scalar keys; append list keys onto what is stored."""
219
+ merged = dict(current)
220
+ for key, value in supplied.items():
221
+ merged[key] = (
222
+ append_metadata_leaf(current.get(key), value)
223
+ if isinstance(value, tuple)
224
+ else value
225
+ )
226
+ return merged
227
+
228
+
229
+ def _scalar(value: MetadataLeaf | None) -> str | None:
230
+ return value if isinstance(value, str) else None
ww/onboarding.py ADDED
@@ -0,0 +1,229 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ """What ww knows about how far the operator and the project are set up.
3
+
4
+ Two places hold it, each for what it describes:
5
+
6
+ - the user level, ``state.json`` in the user configuration directory:
7
+ ``explain``, whether the operator wants the agent to narrate what ww does
8
+ while it learns (absent until they say so);
9
+ - the project, in ``.ww/metadata.json`` under ww's own ``ww.`` namespace, which
10
+ no workflow can save into: ``setup.done``, and when ww last learned about
11
+ the project (``learned.project``).
12
+
13
+ ``ww onboarding`` shows both and sets a known key; it records the operator's
14
+ stated preference, so it asks for no confirmation. ``discover`` reads it to
15
+ tell an agent whether to mention setup on a first use.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ from collections.abc import Sequence
22
+ from dataclasses import dataclass
23
+ from datetime import datetime, timezone
24
+ from pathlib import Path
25
+
26
+ from ww.config_files import display_path, user_directory
27
+ from ww.errors import StateError
28
+ from ww.locking import FileLocks
29
+ from ww.storage_adapters.base import ProjectMetadata, ProjectMetadataStorage
30
+ from ww.workflow_config import WW_METADATA_NAMESPACE
31
+
32
+ USER_STATE_FILE = "state.json"
33
+ EXPLAIN = "explain"
34
+ SETUP_DONE = "setup.done"
35
+ LEARNED = "learned"
36
+ # What ww learns about, and at which level it records when it did.
37
+ LEARNED_USER: tuple[str, ...] = ()
38
+ LEARNED_PROJECT = ("project",)
39
+ USER_KEYS = (EXPLAIN, *(f"{LEARNED}.{name}" for name in LEARNED_USER))
40
+ PROJECT_KEYS = (SETUP_DONE, *(f"{LEARNED}.{name}" for name in LEARNED_PROJECT))
41
+ KEYS = (*USER_KEYS, *PROJECT_KEYS)
42
+ NOW = "now"
43
+ _BOOLEANS = {"true": True, "false": False}
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class OnboardingState:
48
+ """Both levels' onboarding keys; ``None`` is a key never set."""
49
+
50
+ user_file: Path
51
+ project_file: Path
52
+ explain: bool | None
53
+ setup_done: bool
54
+ # When ww last learned about each subject, as an ISO timestamp.
55
+ learned: dict[str, str | None]
56
+
57
+ def to_dict(self) -> dict[str, object]:
58
+ return {
59
+ "user": {
60
+ "file": str(self.user_file),
61
+ EXPLAIN: self.explain,
62
+ **{f"{LEARNED}.{name}": self.learned[name] for name in LEARNED_USER},
63
+ },
64
+ "project": {
65
+ "file": str(self.project_file),
66
+ SETUP_DONE: self.setup_done,
67
+ **{f"{LEARNED}.{name}": self.learned[name] for name in LEARNED_PROJECT},
68
+ },
69
+ }
70
+
71
+
72
+ class Onboarding:
73
+ """Read and set the onboarding keys of one project and its user."""
74
+
75
+ def __init__(self, root: Path, project_metadata: ProjectMetadataStorage) -> None:
76
+ self.root = root
77
+ self.project_metadata = project_metadata
78
+
79
+ @property
80
+ def user_file(self) -> Path:
81
+ return user_directory() / USER_STATE_FILE
82
+
83
+ @property
84
+ def project_file(self) -> Path:
85
+ return self.root / ".ww" / "metadata.json"
86
+
87
+ def read(self) -> OnboardingState:
88
+ user = self._read_user()
89
+ project = self._read_project()
90
+ explain = user.get(EXPLAIN)
91
+ learned_user = user.get(LEARNED)
92
+ learned_user = learned_user if isinstance(learned_user, dict) else {}
93
+ return OnboardingState(
94
+ self.user_file,
95
+ self.project_file,
96
+ explain if isinstance(explain, bool) else None,
97
+ project.get(SETUP_DONE) == "true",
98
+ {
99
+ **{name: _text(learned_user.get(name)) for name in LEARNED_USER},
100
+ **{name: project.get(f"{LEARNED}.{name}") for name in LEARNED_PROJECT},
101
+ },
102
+ )
103
+
104
+ def set(self, assignments: Sequence[str]) -> OnboardingState:
105
+ """Apply ``KEY=VALUE`` assignments, all checked before any is written."""
106
+ values = dict(parse_assignment(item) for item in assignments)
107
+ user = {key: value for key, value in values.items() if key in USER_KEYS}
108
+ project = {key: value for key, value in values.items() if key in PROJECT_KEYS}
109
+ if user:
110
+ self._write_user(user)
111
+ if project:
112
+ self._write_project(project)
113
+ return self.read()
114
+
115
+ def _read_user(self) -> dict[str, object]:
116
+ path = self.user_file
117
+ if not path.is_file():
118
+ return {}
119
+ try:
120
+ data = json.loads(path.read_text(encoding="utf-8"))
121
+ except (OSError, json.JSONDecodeError) as error:
122
+ raise StateError(f"invalid onboarding state {path}: {error}") from error
123
+ if not isinstance(data, dict):
124
+ raise StateError(f"invalid onboarding state {path}: not an object")
125
+ return data
126
+
127
+ def _write_user(self, values: dict[str, bool | str]) -> None:
128
+ data = self._read_user()
129
+ for key, value in values.items():
130
+ if key == EXPLAIN:
131
+ data[EXPLAIN] = value
132
+ continue
133
+ learned = data.get(LEARNED)
134
+ learned = dict(learned) if isinstance(learned, dict) else {}
135
+ learned[key.removeprefix(f"{LEARNED}.")] = value
136
+ data[LEARNED] = learned
137
+ # The user directory is shared by every project; the write only has
138
+ # to be atomic for a concurrent reader.
139
+ FileLocks(self.root).atomic_write(
140
+ self.user_file, json.dumps(data, indent=2, sort_keys=True) + "\n"
141
+ )
142
+
143
+ def _read_project(self) -> dict[str, str]:
144
+ metadata = self.project_metadata.read_project_metadata()
145
+ prefix = f"{WW_METADATA_NAMESPACE}."
146
+ return {
147
+ key.removeprefix(prefix): value
148
+ for key, value in (metadata.values if metadata is not None else ())
149
+ if key.startswith(prefix) and isinstance(value, str)
150
+ }
151
+
152
+ def _write_project(self, values: dict[str, bool | str]) -> None:
153
+ with self.project_metadata.lock_project_metadata():
154
+ metadata = self.project_metadata.read_project_metadata()
155
+ current = dict(metadata.values if metadata is not None else ())
156
+ for key, value in values.items():
157
+ current[f"{WW_METADATA_NAMESPACE}.{key}"] = (
158
+ ("true" if value else "false") if isinstance(value, bool) else value
159
+ )
160
+ try:
161
+ updated = ProjectMetadata(tuple(current.items()))
162
+ except ValueError as error:
163
+ raise StateError(
164
+ f"cannot record onboarding in {self.project_file}: {error}"
165
+ ) from error
166
+ self.project_metadata.write_project_metadata(updated)
167
+
168
+
169
+ def render_onboarding(state: OnboardingState, root: Path) -> str:
170
+ """The keys of both levels as text, each file named where it lives."""
171
+
172
+ def when(name: str) -> str:
173
+ return state.learned[name] or "never"
174
+
175
+ explain = {True: "yes", False: "no", None: "not asked yet"}[state.explain]
176
+ return "\n".join(
177
+ [
178
+ "# ww onboarding",
179
+ "",
180
+ f"User ({display_path(state.user_file, root)}):",
181
+ f"- {EXPLAIN}: {explain}",
182
+ *(f"- {LEARNED}.{name}: {when(name)}" for name in LEARNED_USER),
183
+ "",
184
+ f"Project ({display_path(state.project_file, root)}, ww's own keys):",
185
+ f"- {SETUP_DONE}: {'yes' if state.setup_done else 'no'}",
186
+ *(f"- {LEARNED}.{name}: {when(name)}" for name in LEARNED_PROJECT),
187
+ "",
188
+ "Record a key with `ww onboarding --set KEY=VALUE`.",
189
+ "",
190
+ ]
191
+ )
192
+
193
+
194
+ def parse_assignment(text: str) -> tuple[str, bool | str]:
195
+ """One ``KEY=VALUE``: a known key and its value, checked."""
196
+ key, separator, value = text.partition("=")
197
+ key, value = key.strip(), value.strip()
198
+ if not separator:
199
+ raise StateError(f"--set takes KEY=VALUE, not {text!r}")
200
+ if key not in KEYS:
201
+ raise StateError(
202
+ f"unknown onboarding key {key!r}; the keys are " + ", ".join(KEYS)
203
+ )
204
+ if key in {EXPLAIN, SETUP_DONE}:
205
+ if value not in _BOOLEANS:
206
+ raise StateError(f"{key} takes true or false, not {value!r}")
207
+ return key, _BOOLEANS[value]
208
+ if value == NOW:
209
+ return key, _now()
210
+ try:
211
+ datetime.fromisoformat(value.replace("Z", "+00:00"))
212
+ except ValueError as error:
213
+ raise StateError(
214
+ f"{key} takes `now` or an ISO timestamp, not {value!r}"
215
+ ) from error
216
+ return key, value
217
+
218
+
219
+ def _text(value: object) -> str | None:
220
+ return value if isinstance(value, str) else None
221
+
222
+
223
+ def _now() -> str:
224
+ return (
225
+ datetime.now(timezone.utc)
226
+ .replace(microsecond=0)
227
+ .isoformat()
228
+ .replace("+00:00", "Z")
229
+ )
ww/open_work.py ADDED
@@ -0,0 +1,236 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ """Read-only view of the work still open in a project root.
3
+
4
+ Agent hooks ask two questions of ww's state many times a session: which
5
+ tasks are unfinished, and whether an agent-owned step is being worked on
6
+ right now. Both are answered here from the persisted runs alone, without
7
+ rendering instructions, compiling plans, or loading extensions, so a hook
8
+ stays fast and its answer is exactly what ``instruction`` would report.
9
+
10
+ A task whose record cannot be read is reported beside the others, never
11
+ raised: one broken task must not hide every other task from a scan.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from dataclasses import dataclass
17
+ from datetime import datetime
18
+ from pathlib import Path
19
+
20
+ from ww.contracts import OperatorReason, run_is_open
21
+ from ww.errors import StateError
22
+ from ww.instructions.policy import operator_reason
23
+ from ww.storage_adapters import TaskStorageAdapter
24
+ from ww.workspace import resolve_workspace
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class OpenTask:
29
+ """One task whose selected run is neither completed nor abandoned."""
30
+
31
+ task_id: str
32
+ run_id: str | None
33
+ workflow: str
34
+ # The agent integration the run was started with, such as ``codex``.
35
+ agent: str
36
+ # The plan item at the cursor, when the run has not run past its end.
37
+ item_id: str | None
38
+ item_name: str | None
39
+ step: str | None
40
+ # ``step`` for the step itself; a hook phase such as
41
+ # ``before_complete_workflow`` for work attached to that step.
42
+ phase: str | None
43
+ owner: str | None
44
+ item_status: str | None
45
+ attempt: int
46
+ run_status: str
47
+ # Why the task waits for the operator, or ``None`` when it does not.
48
+ operator_reason: OperatorReason | None
49
+ # The directory the task works in: its worktree, project, or the root.
50
+ workspace: Path
51
+ updated_at: str
52
+ # When the attempt at the cursor's item started, if it has.
53
+ started_at: str | None = None
54
+ # The work item of a per-item stage, which its conversation is filed under.
55
+ work_item_id: str | None = None
56
+ # The step is one the manager hands to a worker (``--runtime auto``), so
57
+ # the session that started the run waits on it rather than holds it.
58
+ delegated: bool = False
59
+ # The step is interactive and its conversation has not ended: the session
60
+ # stops to let the operator speak, so a stop is not a step left open.
61
+ in_conversation: bool = False
62
+
63
+ @property
64
+ def label(self) -> str:
65
+ """How messages name the work: a hook by its own name and its step.
66
+
67
+ A hook item records the step it is attached to, so naming only the
68
+ step would point at a step whose own work may be long finished.
69
+ """
70
+ if self.phase in (None, "step") or not self.item_name:
71
+ return self.step or self.item_name or "no active step"
72
+ if not self.step:
73
+ return self.item_name
74
+ return f"{self.item_name} (a hook of {self.step})"
75
+
76
+ def waiting_on_another(self, open_tasks: tuple[OpenTask, ...]) -> bool:
77
+ """A manager legitimately waits: a child task or a worker is at work.
78
+
79
+ The child is any open task below this one; the worker holds a step
80
+ this task's manager delegated and has not yet finished.
81
+ """
82
+ return (
83
+ self.delegated
84
+ and self.run_status == "in_progress"
85
+ and self.item_status == "in_progress"
86
+ and self.operator_reason is None
87
+ ) or any(task.task_id.startswith(f"{self.task_id}/") for task in open_tasks)
88
+
89
+ @property
90
+ def agent_step_in_progress(self) -> bool:
91
+ """An agent-owned step was dispatched and is neither done nor waiting.
92
+
93
+ Waiting for input, or for the operator, is not work in progress: the
94
+ agent is right to stop and hand the conversation back.
95
+ """
96
+ return (
97
+ self.run_status == "in_progress"
98
+ and self.owner == "agent"
99
+ and self.item_status == "in_progress"
100
+ and self.operator_reason is None
101
+ )
102
+
103
+
104
+ @dataclass(frozen=True)
105
+ class UnreadableTask:
106
+ """A task whose persisted record cannot be read by this build of ww.
107
+
108
+ Commands addressing the task keep failing with ``reason``; scans across
109
+ tasks skip it and name it, so every other task stays usable.
110
+ """
111
+
112
+ task_id: str
113
+ reason: str
114
+
115
+ def to_dict(self) -> dict[str, str]:
116
+ return {"task_id": self.task_id, "reason": self.reason}
117
+
118
+
119
+ @dataclass(frozen=True)
120
+ class OpenWork:
121
+ """The unfinished tasks of a root, and the tasks that could not be read."""
122
+
123
+ tasks: tuple[OpenTask, ...]
124
+ unreadable: tuple[UnreadableTask, ...] = ()
125
+ # Tasks left unread because they were last written before the scan's
126
+ # ``since``, finished or not.
127
+ skipped: int = 0
128
+
129
+
130
+ def open_work(
131
+ tasks: TaskStorageAdapter, root: Path, since: datetime | None = None
132
+ ) -> OpenWork:
133
+ """Every unfinished task in ``root``, children included, newest first.
134
+
135
+ With ``since``, a task last written before it is skipped unread, which
136
+ keeps a scan of a long history cheap.
137
+ """
138
+ found: list[OpenTask] = []
139
+ unreadable: list[UnreadableTask] = []
140
+ skipped = 0
141
+ for task_id in tasks.task_ids():
142
+ for candidate in (task_id, *tasks.child_task_ids(task_id)):
143
+ if since is not None:
144
+ written = tasks.task_written_at(candidate)
145
+ if written is not None and written < since:
146
+ skipped += 1
147
+ continue
148
+ try:
149
+ task = _open_task(tasks, root, candidate)
150
+ except StateError as error:
151
+ unreadable.append(UnreadableTask(candidate, str(error)))
152
+ continue
153
+ if task is not None:
154
+ found.append(task)
155
+ return OpenWork(
156
+ tuple(sorted(found, key=lambda task: task.updated_at, reverse=True)),
157
+ tuple(unreadable),
158
+ skipped,
159
+ )
160
+
161
+
162
+ def _open_task(tasks: TaskStorageAdapter, root: Path, task_id: str) -> OpenTask | None:
163
+ runs, _, _ = tasks.read_task_record(task_id)
164
+ run = next((run for run in reversed(runs) if run_is_open(run.state.status)), None)
165
+ if run is None:
166
+ return None
167
+ state, plan = run.state, run.snapshot.plan
168
+ item = plan.items[state.cursor] if state.cursor < len(plan.items) else None
169
+ record = (
170
+ state.item_executions[state.cursor]
171
+ if state.cursor < len(state.item_executions)
172
+ else None
173
+ )
174
+ active = state.active_item_id is not None and item is not None
175
+ return OpenTask(
176
+ task_id=task_id,
177
+ run_id=state.run_id,
178
+ workflow=state.workflow,
179
+ agent=state.agent,
180
+ item_id=item.id if item is not None else None,
181
+ item_name=item.name if item is not None else None,
182
+ step=item.step if item is not None else None,
183
+ phase=item.phase if item is not None else None,
184
+ owner=item.owner if item is not None else None,
185
+ item_status=(record.status if record is not None and active else None),
186
+ attempt=record.attempts if record is not None else 0,
187
+ run_status=state.status,
188
+ operator_reason=operator_reason(state, plan),
189
+ workspace=resolve_workspace(root, state.working_directory) or root.resolve(),
190
+ updated_at=state.updated_at,
191
+ started_at=record.started_at if record is not None else None,
192
+ work_item_id=item.item_id if item is not None else None,
193
+ delegated=(
194
+ state.workflow_runtime == "auto"
195
+ and item is not None
196
+ and item.role == "worker"
197
+ ),
198
+ in_conversation=(
199
+ item is not None
200
+ and item.interactive
201
+ and record is not None
202
+ and not record.interaction_ended
203
+ ),
204
+ )
205
+
206
+
207
+ def tasks_for_session(
208
+ open_tasks: tuple[OpenTask, ...],
209
+ root: Path,
210
+ directory: Path | None,
211
+ agent: str,
212
+ ) -> tuple[OpenTask, ...]:
213
+ """The tasks a session of ``agent`` working in ``directory`` concerns.
214
+
215
+ A session inside a task's own workspace, a worktree or a project
216
+ checkout, works on that task whichever agent started it; the most
217
+ specific workspace wins, since worktrees usually sit inside the root.
218
+ The root itself is shared by every session and every task without a
219
+ worktree, so it selects nothing: a session there, anywhere else, or in no
220
+ known directory concerns only the tasks its own agent started. Another
221
+ agent's open step is that agent's to close.
222
+ """
223
+ shared = root.resolve()
224
+ if directory is not None:
225
+ resolved = directory.resolve()
226
+ inside = [
227
+ task
228
+ for task in open_tasks
229
+ if task.workspace != shared and resolved.is_relative_to(task.workspace)
230
+ ]
231
+ if inside:
232
+ deepest = max(len(task.workspace.parts) for task in inside)
233
+ return tuple(
234
+ task for task in inside if len(task.workspace.parts) == deepest
235
+ )
236
+ return tuple(task for task in open_tasks if task.agent == agent)