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.
- ww/__init__.py +18 -0
- ww/_bundled_extensions/ww/git/extension.py +1728 -0
- ww/action_execution.py +887 -0
- ww/actions/__init__.py +94 -0
- ww/actions/command.py +444 -0
- ww/actions/contracts.py +699 -0
- ww/actions/extension.py +197 -0
- ww/actions/mcp.py +84 -0
- ww/actions/prompt.py +74 -0
- ww/actions/skill.py +62 -0
- ww/actions/slash_command.py +63 -0
- ww/agents.py +151 -0
- ww/amendments.py +54 -0
- ww/artifacts.py +93 -0
- ww/assessments.py +181 -0
- ww/assets/__init__.py +2 -0
- ww/assets/agent_instructions.md +49 -0
- ww/assets/docs/examples.md +879 -0
- ww/assets/docs/features.md +4639 -0
- ww/assets/docs/specification.md +1876 -0
- ww/assets/noww_skill.md +11 -0
- ww/assets/workflows/catchall.yaml +26 -0
- ww/assets/workflows/onboarding.yaml +586 -0
- ww/assets/workflows/scriptize.yaml +130 -0
- ww/assets/ww-automate_skill.md +23 -0
- ww/assets/ww-deduce-feedback_skill.md +38 -0
- ww/assets/ww-feedback-rules_skill.md +48 -0
- ww/assets/ww-learn-project_skill.md +22 -0
- ww/assets/ww-refresh_skill.md +26 -0
- ww/assets/ww-rule_skill.md +83 -0
- ww/assets/ww-rules-from-artifacts_skill.md +22 -0
- ww/assets/ww-scriptize_skill.md +33 -0
- ww/assets/ww-setup_skill.md +94 -0
- ww/assets/ww-solve_skill.md +23 -0
- ww/assets/ww-suggest_skill.md +32 -0
- ww/assets/ww-wizard_skill.md +105 -0
- ww/assets/ww_skill.md +59 -0
- ww/assignments.py +283 -0
- ww/bootstrap.py +405 -0
- ww/builtin_workflows.py +215 -0
- ww/changes.py +225 -0
- ww/child_coordination.py +482 -0
- ww/children.py +106 -0
- ww/claude_permissions.py +115 -0
- ww/cli/__init__.py +7 -0
- ww/cli/__main__.py +6 -0
- ww/cli/audit.py +129 -0
- ww/cli/catalogs.py +131 -0
- ww/cli/discover.py +607 -0
- ww/cli/initialization.py +898 -0
- ww/cli/lookup.py +287 -0
- ww/cli/main.py +1768 -0
- ww/cli/parser.py +1200 -0
- ww/cli/prompts.py +217 -0
- ww/cli/updates.py +117 -0
- ww/completion_artifacts.py +156 -0
- ww/completion_inputs.py +39 -0
- ww/config/__init__.py +582 -0
- ww/config/actions.py +591 -0
- ww/config/composition.py +571 -0
- ww/config/rules.py +511 -0
- ww/config/steps.py +1220 -0
- ww/config/values.py +223 -0
- ww/config_files.py +191 -0
- ww/config_writes.py +264 -0
- ww/contracts.py +155 -0
- ww/control.py +41 -0
- ww/defaults.py +130 -0
- ww/design_docs.py +32 -0
- ww/discovery.py +104 -0
- ww/documents.py +217 -0
- ww/errors.py +18 -0
- ww/executable.py +43 -0
- ww/execution_models/__init__.py +64 -0
- ww/execution_models/construction.py +148 -0
- ww/execution_models/decoding.py +38 -0
- ww/execution_models/plan_codec.py +565 -0
- ww/execution_models/records.py +1206 -0
- ww/execution_models/runs.py +266 -0
- ww/extensions/__init__.py +40 -0
- ww/extensions/api.py +559 -0
- ww/extensions/registry.py +864 -0
- ww/extensions/store.py +78 -0
- ww/feedback.py +342 -0
- ww/handler_repairs.py +57 -0
- ww/hooks/__init__.py +40 -0
- ww/hooks/agents.py +380 -0
- ww/hooks/install.py +168 -0
- ww/hooks/notices.py +206 -0
- ww/hooks/records.py +209 -0
- ww/hooks/runtime.py +266 -0
- ww/hooks/transcripts.py +183 -0
- ww/inspect.py +896 -0
- ww/instructions/__init__.py +17 -0
- ww/instructions/builder.py +1682 -0
- ww/instructions/commands.py +335 -0
- ww/instructions/handoff.py +149 -0
- ww/instructions/models.py +686 -0
- ww/instructions/policy.py +219 -0
- ww/instructions/text.py +168 -0
- ww/interactions.py +187 -0
- ww/interpolation.py +37 -0
- ww/item_passes.py +167 -0
- ww/items.py +99 -0
- ww/locking.py +207 -0
- ww/metadata_publication.py +230 -0
- ww/onboarding.py +229 -0
- ww/open_work.py +236 -0
- ww/operations.py +193 -0
- ww/operator_ui/__init__.py +16 -0
- ww/operator_ui/page.html +351 -0
- ww/operator_ui/server.py +215 -0
- ww/operator_ui/session.py +389 -0
- ww/operator_ui/sheet.py +104 -0
- ww/operator_ui/view.py +109 -0
- ww/output.py +339 -0
- ww/output_adapters/__init__.py +12 -0
- ww/output_adapters/base.py +25 -0
- ww/output_adapters/json_adapter.py +37 -0
- ww/output_adapters/markdown.py +2293 -0
- ww/output_adapters/rule_pages.py +337 -0
- ww/output_adapters/terminal.py +21 -0
- ww/package_updates.py +167 -0
- ww/plan/__init__.py +38 -0
- ww/plan/actions.py +207 -0
- ww/plan/compiler.py +1492 -0
- ww/plan/constructs.py +456 -0
- ww/plan/models.py +665 -0
- ww/project_config.py +752 -0
- ww/recovery.py +401 -0
- ww/replanning.py +367 -0
- ww/results.py +77 -0
- ww/rule_checks.py +230 -0
- ww/rule_conversion.py +331 -0
- ww/rule_disputes.py +148 -0
- ww/rule_store.py +456 -0
- ww/rule_verification.py +714 -0
- ww/rule_views.py +447 -0
- ww/rule_writes.py +920 -0
- ww/run_coordination.py +158 -0
- ww/runtimes.py +105 -0
- ww/service.py +4405 -0
- ww/setup_apply.py +428 -0
- ww/step_values.py +20 -0
- ww/storage.py +447 -0
- ww/storage_adapters/__init__.py +36 -0
- ww/storage_adapters/base.py +540 -0
- ww/storage_adapters/filesystem.py +370 -0
- ww/storage_adapters/memory.py +195 -0
- ww/storage_adapters/project_metadata.py +69 -0
- ww/storage_adapters/task_document.py +484 -0
- ww/task_ids.py +114 -0
- ww/task_references.py +124 -0
- ww/transitions.py +1619 -0
- ww/updates.py +399 -0
- ww/upgrade.py +95 -0
- ww/validation.py +168 -0
- ww/variables.py +275 -0
- ww/workflow_config.py +854 -0
- ww/workflow_update.py +239 -0
- ww/workflow_validation.py +1260 -0
- ww/workspace.py +50 -0
- ww_agentic_workflows-1.0.0.dev3.dist-info/METADATA +690 -0
- ww_agentic_workflows-1.0.0.dev3.dist-info/RECORD +167 -0
- ww_agentic_workflows-1.0.0.dev3.dist-info/WHEEL +4 -0
- ww_agentic_workflows-1.0.0.dev3.dist-info/entry_points.txt +2 -0
- 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)
|