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
ww/extensions/store.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
"""Durable, namespaced state for one extension.
|
|
3
|
+
|
|
4
|
+
Every extension gets ``.ww/ext/<vendor>/<name>/`` and reaches it only through
|
|
5
|
+
this object. Two properties matter:
|
|
6
|
+
|
|
7
|
+
* **Data lives outside the extension's source.** A pip-installed extension
|
|
8
|
+
cannot write next to its own module, and one vendored into ``ext/`` should not
|
|
9
|
+
put runtime state in the working tree. ``.ww/`` is already gitignored.
|
|
10
|
+
* **Writes use a per-file project lock.** Writes to the same store file are
|
|
11
|
+
serialized, and replacement writes are atomic for unlocked readers.
|
|
12
|
+
``update_text`` keeps the lock across a complete read–modify–write sequence;
|
|
13
|
+
separate reads and writes do not form one transaction.
|
|
14
|
+
|
|
15
|
+
The surface is narrow on purpose: an extension that could name arbitrary paths
|
|
16
|
+
could write anywhere in the project.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from collections.abc import Callable
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
from ww.errors import ConfigurationError
|
|
25
|
+
from ww.locking import FileLocks
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ExtensionStore:
|
|
29
|
+
"""Read and write files under one extension's private directory."""
|
|
30
|
+
|
|
31
|
+
def __init__(self, root: Path, identifier: str) -> None:
|
|
32
|
+
self.root = Path(root)
|
|
33
|
+
self.identifier = identifier
|
|
34
|
+
self.directory = self.root / ".ww" / "ext" / identifier
|
|
35
|
+
self._locks = FileLocks(root)
|
|
36
|
+
|
|
37
|
+
def path(self, name: str) -> Path:
|
|
38
|
+
"""Return the absolute path of ``name`` inside this extension's store."""
|
|
39
|
+
if not name or name.startswith(".") or "/" in name or "\\" in name:
|
|
40
|
+
raise ConfigurationError(
|
|
41
|
+
f"extension store file name must be a plain file name: {name!r}"
|
|
42
|
+
)
|
|
43
|
+
return self.directory / name
|
|
44
|
+
|
|
45
|
+
def append_line(self, name: str, line: str) -> None:
|
|
46
|
+
self._locks.append_line(self.path(name), line)
|
|
47
|
+
|
|
48
|
+
def write_text(self, name: str, content: str) -> None:
|
|
49
|
+
path = self.path(name)
|
|
50
|
+
purpose = f"extension store {self.identifier}/{name}"
|
|
51
|
+
with self._locks.lock(path, purpose=purpose):
|
|
52
|
+
self._locks.atomic_write(path, content)
|
|
53
|
+
|
|
54
|
+
def update_text(self, name: str, update: Callable[[str | None], str]) -> str:
|
|
55
|
+
"""Atomically update one file while holding its lock across the read.
|
|
56
|
+
|
|
57
|
+
``write_text`` protects readers from partial files, but callers that
|
|
58
|
+
derive new content from old content need this operation to avoid lost
|
|
59
|
+
updates. If ``update`` raises or returns a non-string, the file is left
|
|
60
|
+
unchanged.
|
|
61
|
+
"""
|
|
62
|
+
path = self.path(name)
|
|
63
|
+
purpose = f"extension store {self.identifier}/{name}"
|
|
64
|
+
with self._locks.lock(path, purpose=purpose):
|
|
65
|
+
current = path.read_text(encoding="utf-8") if path.is_file() else None
|
|
66
|
+
content = update(current)
|
|
67
|
+
if not isinstance(content, str):
|
|
68
|
+
raise TypeError("extension store update must return a string")
|
|
69
|
+
self._locks.atomic_write(path, content)
|
|
70
|
+
return content
|
|
71
|
+
|
|
72
|
+
def read_text(self, name: str) -> str | None:
|
|
73
|
+
path = self.path(name)
|
|
74
|
+
return path.read_text(encoding="utf-8") if path.is_file() else None
|
|
75
|
+
|
|
76
|
+
def read_lines(self, name: str) -> tuple[str, ...]:
|
|
77
|
+
content = self.read_text(name)
|
|
78
|
+
return tuple(content.splitlines()) if content else ()
|
ww/feedback.py
ADDED
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
"""Agent-generalised feedback candidates; no candidate is an installed rule."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import threading
|
|
8
|
+
import uuid
|
|
9
|
+
from collections.abc import Iterator
|
|
10
|
+
from contextlib import AbstractContextManager, contextmanager
|
|
11
|
+
from copy import deepcopy
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from ww.errors import StateError
|
|
16
|
+
from ww.storage import Storage
|
|
17
|
+
from ww.task_ids import validate_task_id
|
|
18
|
+
|
|
19
|
+
STORE_SCHEMA = 1
|
|
20
|
+
# Rule review may prune after five subsequently completed unmatched tasks.
|
|
21
|
+
STALE_TASKS = 5
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class FeedbackStore:
|
|
25
|
+
"""Project-scoped, locked storage shared by tasks and their worktrees."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, storage: Storage) -> None:
|
|
28
|
+
self.storage = storage
|
|
29
|
+
self.path = storage.runtime_path / "feedback.json"
|
|
30
|
+
|
|
31
|
+
def _lock(self) -> AbstractContextManager[None]:
|
|
32
|
+
return self.storage.locks.lock(self.path, purpose="operator feedback")
|
|
33
|
+
|
|
34
|
+
def _load(self) -> dict[str, Any]:
|
|
35
|
+
if not self.path.exists():
|
|
36
|
+
return {"schema": STORE_SCHEMA, "tasks": [], "completed": [], "points": {}}
|
|
37
|
+
try:
|
|
38
|
+
data = json.loads(self.path.read_text(encoding="utf-8"))
|
|
39
|
+
if not isinstance(data, dict) or data.get("schema") != STORE_SCHEMA:
|
|
40
|
+
raise ValueError("unsupported feedback store schema")
|
|
41
|
+
for key in ("tasks", "completed"):
|
|
42
|
+
if not isinstance(data.get(key), list) or any(
|
|
43
|
+
not isinstance(task, str) for task in data[key]
|
|
44
|
+
):
|
|
45
|
+
raise ValueError(f"{key} must be a list of task IDs")
|
|
46
|
+
if not isinstance(data.get("points"), dict):
|
|
47
|
+
raise ValueError("points must be an object")
|
|
48
|
+
for point in data["points"].values():
|
|
49
|
+
if not isinstance(point, dict):
|
|
50
|
+
raise ValueError("point must be an object")
|
|
51
|
+
_point_fields(point)
|
|
52
|
+
if not isinstance(point.get("events"), list):
|
|
53
|
+
raise ValueError("events must be a list")
|
|
54
|
+
if type(point.get("last_seen")) is not int:
|
|
55
|
+
raise ValueError("last_seen must be an integer")
|
|
56
|
+
for event in point["events"]:
|
|
57
|
+
if not isinstance(event, dict):
|
|
58
|
+
raise ValueError("event must be an object")
|
|
59
|
+
_text(event, "task")
|
|
60
|
+
_text(event, "quote")
|
|
61
|
+
if "entry" in event:
|
|
62
|
+
if type(event["entry"]) is not int:
|
|
63
|
+
raise ValueError("event entry must be an integer")
|
|
64
|
+
else:
|
|
65
|
+
_text(event, "source")
|
|
66
|
+
_timestamp(event.get("at"))
|
|
67
|
+
point["last_encountered_at"] = _last_encountered(point["events"])
|
|
68
|
+
return dict(data)
|
|
69
|
+
except (OSError, ValueError, StateError) as error:
|
|
70
|
+
raise StateError(f"invalid feedback store {self.path}: {error}") from error
|
|
71
|
+
|
|
72
|
+
def _save(self, data: dict[str, Any]) -> None:
|
|
73
|
+
self.storage.locks.atomic_write(self.path, json.dumps(data, indent=2) + "\n")
|
|
74
|
+
|
|
75
|
+
def get(self, identifier: str) -> dict[str, object]:
|
|
76
|
+
points = self.listing()["points"]
|
|
77
|
+
assert isinstance(points, list)
|
|
78
|
+
for point in points:
|
|
79
|
+
if point["id"] == identifier:
|
|
80
|
+
return dict(point)
|
|
81
|
+
raise StateError(f"unknown feedback point {identifier!r}")
|
|
82
|
+
|
|
83
|
+
def listing(self) -> dict[str, object]:
|
|
84
|
+
data = self._load()
|
|
85
|
+
total = len(data["tasks"])
|
|
86
|
+
completed = set(data["completed"])
|
|
87
|
+
points = []
|
|
88
|
+
for identifier, point in data["points"].items():
|
|
89
|
+
task_ids = {event["task"] for event in point["events"]}
|
|
90
|
+
points.append(
|
|
91
|
+
{
|
|
92
|
+
"id": identifier,
|
|
93
|
+
**point,
|
|
94
|
+
"occurrences": len(point["events"]),
|
|
95
|
+
"task_occurrences": len(task_ids),
|
|
96
|
+
"task_ratio": len(task_ids) / total if total else 0,
|
|
97
|
+
"occurrence_ratio": len(point["events"]) / total if total else 0,
|
|
98
|
+
"completed_task_ratio": (
|
|
99
|
+
len(task_ids & completed) / len(completed) if completed else 0
|
|
100
|
+
),
|
|
101
|
+
"tasks_since_last_seen": len(data["completed"])
|
|
102
|
+
- point["last_seen"],
|
|
103
|
+
}
|
|
104
|
+
)
|
|
105
|
+
return {
|
|
106
|
+
"total_tasks": total,
|
|
107
|
+
"completed_tasks": len(completed),
|
|
108
|
+
"retire_after_tasks": STALE_TASKS,
|
|
109
|
+
"points": points,
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
def record(
|
|
113
|
+
self,
|
|
114
|
+
task_id: str,
|
|
115
|
+
run_id: str | None,
|
|
116
|
+
analysis: object,
|
|
117
|
+
sources: object,
|
|
118
|
+
) -> dict[str, object]:
|
|
119
|
+
"""Record deductions with quoted artifact evidence and explicit match IDs."""
|
|
120
|
+
validate_task_id(task_id)
|
|
121
|
+
if not isinstance(analysis, list):
|
|
122
|
+
raise StateError("feedback analysis must be a JSON array")
|
|
123
|
+
if not isinstance(sources, list):
|
|
124
|
+
raise StateError("feedback sources must be a list")
|
|
125
|
+
by_id = {source["id"]: source for source in sources}
|
|
126
|
+
prepared = []
|
|
127
|
+
for raw in analysis:
|
|
128
|
+
if not isinstance(raw, dict):
|
|
129
|
+
raise StateError("each feedback point must be an object")
|
|
130
|
+
unknown = set(raw) - {
|
|
131
|
+
"id",
|
|
132
|
+
"summary",
|
|
133
|
+
"reason",
|
|
134
|
+
"enforcement",
|
|
135
|
+
"approach",
|
|
136
|
+
"evidence",
|
|
137
|
+
}
|
|
138
|
+
if unknown:
|
|
139
|
+
raise StateError(
|
|
140
|
+
"unknown feedback fields: " + ", ".join(sorted(unknown))
|
|
141
|
+
)
|
|
142
|
+
fields = _point_fields(raw)
|
|
143
|
+
identifier = raw.get("id")
|
|
144
|
+
if identifier is not None and (
|
|
145
|
+
not isinstance(identifier, str) or not identifier.strip()
|
|
146
|
+
):
|
|
147
|
+
raise StateError("feedback id must be a non-empty string")
|
|
148
|
+
evidence = raw.get("evidence")
|
|
149
|
+
if not isinstance(evidence, list) or not evidence:
|
|
150
|
+
raise StateError("feedback evidence must be a non-empty list")
|
|
151
|
+
events = []
|
|
152
|
+
for supplied in evidence:
|
|
153
|
+
if not isinstance(supplied, dict) or set(supplied) != {
|
|
154
|
+
"source",
|
|
155
|
+
"quote",
|
|
156
|
+
}:
|
|
157
|
+
raise StateError("feedback evidence needs source and quote")
|
|
158
|
+
source_id = _text(supplied, "source")
|
|
159
|
+
source = by_id.get(source_id)
|
|
160
|
+
if source is None:
|
|
161
|
+
raise StateError(
|
|
162
|
+
"feedback source is not a completed learnable artifact"
|
|
163
|
+
)
|
|
164
|
+
quote = _text(supplied, "quote")
|
|
165
|
+
if quote not in source["content"]:
|
|
166
|
+
raise StateError("feedback quote does not occur in its artifact")
|
|
167
|
+
events.append(
|
|
168
|
+
{
|
|
169
|
+
"task": task_id,
|
|
170
|
+
"run_id": run_id,
|
|
171
|
+
"source": source_id,
|
|
172
|
+
"artifact": source["artifact"],
|
|
173
|
+
"step": source["step"],
|
|
174
|
+
"quote": quote,
|
|
175
|
+
"at": source["encountered_at"],
|
|
176
|
+
}
|
|
177
|
+
)
|
|
178
|
+
prepared.append((identifier, fields, events))
|
|
179
|
+
with self._lock():
|
|
180
|
+
data = self._load()
|
|
181
|
+
if task_id not in data["tasks"]:
|
|
182
|
+
data["tasks"].append(task_id)
|
|
183
|
+
if task_id not in data["completed"]:
|
|
184
|
+
data["completed"].append(task_id)
|
|
185
|
+
for identifier, fields, events in prepared:
|
|
186
|
+
if identifier is not None and identifier not in data["points"]:
|
|
187
|
+
raise StateError(
|
|
188
|
+
f"unknown feedback point {identifier!r}; list first"
|
|
189
|
+
)
|
|
190
|
+
if identifier is None:
|
|
191
|
+
existing = next(
|
|
192
|
+
(
|
|
193
|
+
key
|
|
194
|
+
for key, point in data["points"].items()
|
|
195
|
+
if point["summary"].casefold()
|
|
196
|
+
== fields["summary"].casefold()
|
|
197
|
+
),
|
|
198
|
+
None,
|
|
199
|
+
)
|
|
200
|
+
if existing is not None:
|
|
201
|
+
seen = {
|
|
202
|
+
_event_key(event)
|
|
203
|
+
for event in data["points"][existing]["events"]
|
|
204
|
+
}
|
|
205
|
+
if any(_event_key(event) not in seen for event in events):
|
|
206
|
+
raise StateError(
|
|
207
|
+
f"existing feedback point {existing!r}; "
|
|
208
|
+
"pass its id to update"
|
|
209
|
+
)
|
|
210
|
+
identifier = existing
|
|
211
|
+
else:
|
|
212
|
+
identifier = "feedback-" + uuid.uuid4().hex[:12]
|
|
213
|
+
point = data["points"].setdefault(identifier, {"events": []})
|
|
214
|
+
seen = {_event_key(event) for event in point["events"]}
|
|
215
|
+
for event in events:
|
|
216
|
+
key = _event_key(event)
|
|
217
|
+
if key not in seen:
|
|
218
|
+
point["events"].append(event)
|
|
219
|
+
seen.add(key)
|
|
220
|
+
point.update(fields)
|
|
221
|
+
point["last_seen"] = max(
|
|
222
|
+
(
|
|
223
|
+
data["completed"].index(event["task"]) + 1
|
|
224
|
+
for event in point["events"]
|
|
225
|
+
if event["task"] in data["completed"]
|
|
226
|
+
),
|
|
227
|
+
default=0,
|
|
228
|
+
)
|
|
229
|
+
point["last_encountered_at"] = _last_encountered(point["events"])
|
|
230
|
+
self._save(data)
|
|
231
|
+
return self.listing()
|
|
232
|
+
|
|
233
|
+
def complete_task(self, task_id: str) -> None:
|
|
234
|
+
"""Count completed task exposure once; never deduce or delete candidates."""
|
|
235
|
+
with self._lock():
|
|
236
|
+
data = self._load()
|
|
237
|
+
if task_id in data["completed"]:
|
|
238
|
+
return
|
|
239
|
+
if task_id not in data["tasks"]:
|
|
240
|
+
data["tasks"].append(task_id)
|
|
241
|
+
data["completed"].append(task_id)
|
|
242
|
+
for point in data["points"].values():
|
|
243
|
+
if any(event["task"] == task_id for event in point["events"]):
|
|
244
|
+
point["last_seen"] = len(data["completed"])
|
|
245
|
+
self._save(data)
|
|
246
|
+
|
|
247
|
+
def prune(
|
|
248
|
+
self,
|
|
249
|
+
*,
|
|
250
|
+
dry_run: bool = False,
|
|
251
|
+
keep: tuple[str, ...] = (),
|
|
252
|
+
) -> dict[str, object]:
|
|
253
|
+
"""Explicit maintenance called by rule review, never by workflow completion."""
|
|
254
|
+
with self._lock():
|
|
255
|
+
data = self._load()
|
|
256
|
+
unknown = set(keep) - set(data["points"])
|
|
257
|
+
if unknown:
|
|
258
|
+
raise StateError(
|
|
259
|
+
"unknown feedback point(s): " + ", ".join(sorted(unknown))
|
|
260
|
+
)
|
|
261
|
+
stale = []
|
|
262
|
+
for identifier, point in data["points"].items():
|
|
263
|
+
pending = {event["task"] for event in point["events"]} - set(
|
|
264
|
+
data["completed"]
|
|
265
|
+
)
|
|
266
|
+
if (
|
|
267
|
+
identifier not in keep
|
|
268
|
+
and not pending
|
|
269
|
+
and len(data["completed"]) - point["last_seen"] >= STALE_TASKS
|
|
270
|
+
):
|
|
271
|
+
stale.append(identifier)
|
|
272
|
+
if not dry_run and stale:
|
|
273
|
+
for identifier in stale:
|
|
274
|
+
del data["points"][identifier]
|
|
275
|
+
self._save(data)
|
|
276
|
+
return {"dry_run": dry_run, "pruned": stale, "kept": list(keep)}
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _event_key(event: dict[str, Any]) -> tuple[object, ...]:
|
|
280
|
+
# Legacy transcript points remain readable without losing IDs or counts.
|
|
281
|
+
source = event.get("source", f"interaction:{event.get('entry')}")
|
|
282
|
+
return event["task"], event.get("run_id"), source, event["quote"]
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _timestamp(value: object) -> datetime:
|
|
286
|
+
if not isinstance(value, str):
|
|
287
|
+
raise StateError("feedback encounter time must be an ISO timestamp")
|
|
288
|
+
try:
|
|
289
|
+
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
290
|
+
if parsed.tzinfo is None:
|
|
291
|
+
raise ValueError("timezone is required")
|
|
292
|
+
return parsed.astimezone(timezone.utc)
|
|
293
|
+
except ValueError as error:
|
|
294
|
+
raise StateError(f"invalid feedback encounter time: {value!r}") from error
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _last_encountered(events: list[dict[str, Any]]) -> str | None:
|
|
298
|
+
return (
|
|
299
|
+
max(events, key=lambda event: _timestamp(event["at"]))["at"] if events else None
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _text(raw: dict[str, Any], key: str) -> str:
|
|
304
|
+
value = raw.get(key)
|
|
305
|
+
if not isinstance(value, str) or not value.strip():
|
|
306
|
+
raise StateError(f"feedback {key} must be a non-empty string")
|
|
307
|
+
return value.strip()
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _point_fields(raw: dict[str, Any]) -> dict[str, str]:
|
|
311
|
+
fields = {
|
|
312
|
+
key: _text(raw, key) for key in ("summary", "reason", "enforcement", "approach")
|
|
313
|
+
}
|
|
314
|
+
if fields["enforcement"] not in {"scripted", "reasoning"}:
|
|
315
|
+
raise StateError("feedback enforcement must be scripted or reasoning")
|
|
316
|
+
return fields
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
class MemoryFeedbackStore(FeedbackStore):
|
|
320
|
+
"""Injectable candidate storage for embedded services without disk writes."""
|
|
321
|
+
|
|
322
|
+
def __init__(self, storage: Storage) -> None:
|
|
323
|
+
super().__init__(storage)
|
|
324
|
+
self.data: dict[str, Any] = {
|
|
325
|
+
"schema": STORE_SCHEMA,
|
|
326
|
+
"tasks": [],
|
|
327
|
+
"completed": [],
|
|
328
|
+
"points": {},
|
|
329
|
+
}
|
|
330
|
+
self.guard = threading.RLock()
|
|
331
|
+
|
|
332
|
+
@contextmanager
|
|
333
|
+
def _lock(self) -> Iterator[None]:
|
|
334
|
+
with self.guard:
|
|
335
|
+
yield
|
|
336
|
+
|
|
337
|
+
def _load(self) -> dict[str, Any]:
|
|
338
|
+
with self.guard:
|
|
339
|
+
return deepcopy(self.data)
|
|
340
|
+
|
|
341
|
+
def _save(self, data: dict[str, Any]) -> None:
|
|
342
|
+
self.data = deepcopy(data)
|
ww/handler_repairs.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
"""Persistent agent repairs of known failures of automated command items."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from dataclasses import replace
|
|
7
|
+
|
|
8
|
+
from ww.execution_models import ExecutionState
|
|
9
|
+
from ww.plan import PlanItem, WorkflowPlan
|
|
10
|
+
from ww.transitions import Clock, project_steps
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def needs_repair(state: ExecutionState) -> bool:
|
|
14
|
+
return (
|
|
15
|
+
state.cursor < len(state.item_executions)
|
|
16
|
+
and state.item_executions[state.cursor].repair_pending
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def close_assignment(state: ExecutionState) -> ExecutionState:
|
|
21
|
+
"""End the current worker's authority before dispatching another assignment."""
|
|
22
|
+
return replace(
|
|
23
|
+
state,
|
|
24
|
+
assignment_item_id=None,
|
|
25
|
+
assignment_token=None,
|
|
26
|
+
assignment_model=None,
|
|
27
|
+
assignment_reasoning=None,
|
|
28
|
+
assignment_selected_agent=None,
|
|
29
|
+
assignment_selected_model=None,
|
|
30
|
+
assignment_selected_reasoning=None,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def request_repair(
|
|
35
|
+
state: ExecutionState, plan: WorkflowPlan, item: PlanItem, now: Clock
|
|
36
|
+
) -> ExecutionState:
|
|
37
|
+
"""Keep the failed command and its evidence in place while a worker fixes it."""
|
|
38
|
+
records = list(state.item_executions)
|
|
39
|
+
record = records[state.cursor]
|
|
40
|
+
failures = record.repair_failures + 1
|
|
41
|
+
records[state.cursor] = replace(
|
|
42
|
+
record, repair_pending=True, repair_failures=failures
|
|
43
|
+
)
|
|
44
|
+
limited = failures >= item.max_handler_fixes
|
|
45
|
+
active = not limited and state.workflow_runtime == "single"
|
|
46
|
+
return project_steps(
|
|
47
|
+
replace(
|
|
48
|
+
close_assignment(state),
|
|
49
|
+
item_executions=tuple(records),
|
|
50
|
+
status="failed" if limited else "in_progress" if active else "pending",
|
|
51
|
+
active_item_id=item.id if active else None,
|
|
52
|
+
failure_kind="fix_limit" if limited else None,
|
|
53
|
+
updated_at=now(),
|
|
54
|
+
),
|
|
55
|
+
plan,
|
|
56
|
+
now,
|
|
57
|
+
)
|
ww/hooks/__init__.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
"""Native agent hooks: resume context, a one-time stop reminder, and
|
|
3
|
+
interruption records, translated per agent by small adapters."""
|
|
4
|
+
|
|
5
|
+
from .agents import HOOK_AGENTS, HOOK_EVENTS, HookAgent, HookEvent, hook_agent
|
|
6
|
+
from .install import (
|
|
7
|
+
HookInstallation,
|
|
8
|
+
HookInstallError,
|
|
9
|
+
hook_snippet,
|
|
10
|
+
hooks_file,
|
|
11
|
+
hooks_installed,
|
|
12
|
+
install_hooks,
|
|
13
|
+
manual_instructions,
|
|
14
|
+
registered_elsewhere,
|
|
15
|
+
uninstall_hooks,
|
|
16
|
+
)
|
|
17
|
+
from .records import HookRecords, Interruption
|
|
18
|
+
from .runtime import HookAnswer, answer_hook, is_project_root
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"HOOK_AGENTS",
|
|
22
|
+
"HOOK_EVENTS",
|
|
23
|
+
"HookAgent",
|
|
24
|
+
"HookAnswer",
|
|
25
|
+
"HookEvent",
|
|
26
|
+
"HookInstallError",
|
|
27
|
+
"HookInstallation",
|
|
28
|
+
"HookRecords",
|
|
29
|
+
"Interruption",
|
|
30
|
+
"answer_hook",
|
|
31
|
+
"hook_agent",
|
|
32
|
+
"hook_snippet",
|
|
33
|
+
"hooks_file",
|
|
34
|
+
"hooks_installed",
|
|
35
|
+
"install_hooks",
|
|
36
|
+
"is_project_root",
|
|
37
|
+
"manual_instructions",
|
|
38
|
+
"registered_elsewhere",
|
|
39
|
+
"uninstall_hooks",
|
|
40
|
+
]
|