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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (167) hide show
  1. ww/__init__.py +18 -0
  2. ww/_bundled_extensions/ww/git/extension.py +1728 -0
  3. ww/action_execution.py +887 -0
  4. ww/actions/__init__.py +94 -0
  5. ww/actions/command.py +444 -0
  6. ww/actions/contracts.py +699 -0
  7. ww/actions/extension.py +197 -0
  8. ww/actions/mcp.py +84 -0
  9. ww/actions/prompt.py +74 -0
  10. ww/actions/skill.py +62 -0
  11. ww/actions/slash_command.py +63 -0
  12. ww/agents.py +151 -0
  13. ww/amendments.py +54 -0
  14. ww/artifacts.py +93 -0
  15. ww/assessments.py +181 -0
  16. ww/assets/__init__.py +2 -0
  17. ww/assets/agent_instructions.md +49 -0
  18. ww/assets/docs/examples.md +879 -0
  19. ww/assets/docs/features.md +4639 -0
  20. ww/assets/docs/specification.md +1876 -0
  21. ww/assets/noww_skill.md +11 -0
  22. ww/assets/workflows/catchall.yaml +26 -0
  23. ww/assets/workflows/onboarding.yaml +586 -0
  24. ww/assets/workflows/scriptize.yaml +130 -0
  25. ww/assets/ww-automate_skill.md +23 -0
  26. ww/assets/ww-deduce-feedback_skill.md +38 -0
  27. ww/assets/ww-feedback-rules_skill.md +48 -0
  28. ww/assets/ww-learn-project_skill.md +22 -0
  29. ww/assets/ww-refresh_skill.md +26 -0
  30. ww/assets/ww-rule_skill.md +83 -0
  31. ww/assets/ww-rules-from-artifacts_skill.md +22 -0
  32. ww/assets/ww-scriptize_skill.md +33 -0
  33. ww/assets/ww-setup_skill.md +94 -0
  34. ww/assets/ww-solve_skill.md +23 -0
  35. ww/assets/ww-suggest_skill.md +32 -0
  36. ww/assets/ww-wizard_skill.md +105 -0
  37. ww/assets/ww_skill.md +59 -0
  38. ww/assignments.py +283 -0
  39. ww/bootstrap.py +405 -0
  40. ww/builtin_workflows.py +215 -0
  41. ww/changes.py +225 -0
  42. ww/child_coordination.py +482 -0
  43. ww/children.py +106 -0
  44. ww/claude_permissions.py +115 -0
  45. ww/cli/__init__.py +7 -0
  46. ww/cli/__main__.py +6 -0
  47. ww/cli/audit.py +129 -0
  48. ww/cli/catalogs.py +131 -0
  49. ww/cli/discover.py +607 -0
  50. ww/cli/initialization.py +898 -0
  51. ww/cli/lookup.py +287 -0
  52. ww/cli/main.py +1768 -0
  53. ww/cli/parser.py +1200 -0
  54. ww/cli/prompts.py +217 -0
  55. ww/cli/updates.py +117 -0
  56. ww/completion_artifacts.py +156 -0
  57. ww/completion_inputs.py +39 -0
  58. ww/config/__init__.py +582 -0
  59. ww/config/actions.py +591 -0
  60. ww/config/composition.py +571 -0
  61. ww/config/rules.py +511 -0
  62. ww/config/steps.py +1220 -0
  63. ww/config/values.py +223 -0
  64. ww/config_files.py +191 -0
  65. ww/config_writes.py +264 -0
  66. ww/contracts.py +155 -0
  67. ww/control.py +41 -0
  68. ww/defaults.py +130 -0
  69. ww/design_docs.py +32 -0
  70. ww/discovery.py +104 -0
  71. ww/documents.py +217 -0
  72. ww/errors.py +18 -0
  73. ww/executable.py +43 -0
  74. ww/execution_models/__init__.py +64 -0
  75. ww/execution_models/construction.py +148 -0
  76. ww/execution_models/decoding.py +38 -0
  77. ww/execution_models/plan_codec.py +565 -0
  78. ww/execution_models/records.py +1206 -0
  79. ww/execution_models/runs.py +266 -0
  80. ww/extensions/__init__.py +40 -0
  81. ww/extensions/api.py +559 -0
  82. ww/extensions/registry.py +864 -0
  83. ww/extensions/store.py +78 -0
  84. ww/feedback.py +342 -0
  85. ww/handler_repairs.py +57 -0
  86. ww/hooks/__init__.py +40 -0
  87. ww/hooks/agents.py +380 -0
  88. ww/hooks/install.py +168 -0
  89. ww/hooks/notices.py +206 -0
  90. ww/hooks/records.py +209 -0
  91. ww/hooks/runtime.py +266 -0
  92. ww/hooks/transcripts.py +183 -0
  93. ww/inspect.py +896 -0
  94. ww/instructions/__init__.py +17 -0
  95. ww/instructions/builder.py +1682 -0
  96. ww/instructions/commands.py +335 -0
  97. ww/instructions/handoff.py +149 -0
  98. ww/instructions/models.py +686 -0
  99. ww/instructions/policy.py +219 -0
  100. ww/instructions/text.py +168 -0
  101. ww/interactions.py +187 -0
  102. ww/interpolation.py +37 -0
  103. ww/item_passes.py +167 -0
  104. ww/items.py +99 -0
  105. ww/locking.py +207 -0
  106. ww/metadata_publication.py +230 -0
  107. ww/onboarding.py +229 -0
  108. ww/open_work.py +236 -0
  109. ww/operations.py +193 -0
  110. ww/operator_ui/__init__.py +16 -0
  111. ww/operator_ui/page.html +351 -0
  112. ww/operator_ui/server.py +215 -0
  113. ww/operator_ui/session.py +389 -0
  114. ww/operator_ui/sheet.py +104 -0
  115. ww/operator_ui/view.py +109 -0
  116. ww/output.py +339 -0
  117. ww/output_adapters/__init__.py +12 -0
  118. ww/output_adapters/base.py +25 -0
  119. ww/output_adapters/json_adapter.py +37 -0
  120. ww/output_adapters/markdown.py +2293 -0
  121. ww/output_adapters/rule_pages.py +337 -0
  122. ww/output_adapters/terminal.py +21 -0
  123. ww/package_updates.py +167 -0
  124. ww/plan/__init__.py +38 -0
  125. ww/plan/actions.py +207 -0
  126. ww/plan/compiler.py +1492 -0
  127. ww/plan/constructs.py +456 -0
  128. ww/plan/models.py +665 -0
  129. ww/project_config.py +752 -0
  130. ww/recovery.py +401 -0
  131. ww/replanning.py +367 -0
  132. ww/results.py +77 -0
  133. ww/rule_checks.py +230 -0
  134. ww/rule_conversion.py +331 -0
  135. ww/rule_disputes.py +148 -0
  136. ww/rule_store.py +456 -0
  137. ww/rule_verification.py +714 -0
  138. ww/rule_views.py +447 -0
  139. ww/rule_writes.py +920 -0
  140. ww/run_coordination.py +158 -0
  141. ww/runtimes.py +105 -0
  142. ww/service.py +4405 -0
  143. ww/setup_apply.py +428 -0
  144. ww/step_values.py +20 -0
  145. ww/storage.py +447 -0
  146. ww/storage_adapters/__init__.py +36 -0
  147. ww/storage_adapters/base.py +540 -0
  148. ww/storage_adapters/filesystem.py +370 -0
  149. ww/storage_adapters/memory.py +195 -0
  150. ww/storage_adapters/project_metadata.py +69 -0
  151. ww/storage_adapters/task_document.py +484 -0
  152. ww/task_ids.py +114 -0
  153. ww/task_references.py +124 -0
  154. ww/transitions.py +1619 -0
  155. ww/updates.py +399 -0
  156. ww/upgrade.py +95 -0
  157. ww/validation.py +168 -0
  158. ww/variables.py +275 -0
  159. ww/workflow_config.py +854 -0
  160. ww/workflow_update.py +239 -0
  161. ww/workflow_validation.py +1260 -0
  162. ww/workspace.py +50 -0
  163. ww_agentic_workflows-1.0.0.dev3.dist-info/METADATA +690 -0
  164. ww_agentic_workflows-1.0.0.dev3.dist-info/RECORD +167 -0
  165. ww_agentic_workflows-1.0.0.dev3.dist-info/WHEEL +4 -0
  166. ww_agentic_workflows-1.0.0.dev3.dist-info/entry_points.txt +2 -0
  167. ww_agentic_workflows-1.0.0.dev3.dist-info/licenses/LICENSE +674 -0
ww/hooks/notices.py ADDED
@@ -0,0 +1,206 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ """The few lines agent hooks and task commands show about open work.
3
+
4
+ Whatever a session-start hook prints stays in the agent's context for the
5
+ rest of the session, so every text here is short and plain: no headings,
6
+ one line per task, and a cap on how many tasks are listed.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from pathlib import Path
12
+
13
+ from ww.executable import ww_command
14
+ from ww.open_work import OpenTask, UnreadableTask
15
+
16
+ from .records import Interruption
17
+
18
+ SESSION_TASK_LIMIT = 5
19
+ STOP_TASK_LIMIT = 3
20
+
21
+ _OPERATOR_REASONS = {
22
+ "handler_failed": "an automatic handler failed",
23
+ "work_failed": "the work failed",
24
+ "child_failed": "a child task failed",
25
+ "handler_interrupted": "a command was interrupted",
26
+ "loop_limit": "a loop reached its limit",
27
+ "fix_limit": "a step's checks kept failing",
28
+ "check_disputed": "a worker disputed a check",
29
+ "value_unavailable": "a step's template value is not available yet",
30
+ "pass_incomplete": "an items pass left items without their recorded outcome",
31
+ }
32
+
33
+
34
+ def session_context(
35
+ open_tasks: tuple[OpenTask, ...],
36
+ interruptions: dict[str, Interruption],
37
+ root: Path,
38
+ *,
39
+ compacted: bool,
40
+ unreadable: tuple[UnreadableTask, ...] = (),
41
+ on_request: bool = False,
42
+ skipped: int = 0,
43
+ ) -> str:
44
+ """What a session learns about ww when it starts, resumes, or compacts.
45
+
46
+ ``open_tasks`` are the recent unfinished tasks; ``skipped`` counts the
47
+ tasks the scan left unread as older, which ``discover`` still covers.
48
+ A step in progress without an interruption marker ended with no hook
49
+ run, so it gets a notice of its own, except after a compaction: the
50
+ session that holds it is the one carrying on.
51
+ """
52
+ ww = ww_command()
53
+ lines = []
54
+ if compacted:
55
+ lines.append("Context was compacted; ww's task state is authoritative.")
56
+ lines.append(
57
+ "This project has ww available on request only: use it only when the "
58
+ "user explicitly asks for ww; otherwise work without it and do not "
59
+ f"ask. `{ww} discover` lists its workflows."
60
+ if on_request
61
+ else f"This project coordinates work through ww: `{ww} discover` lists "
62
+ "its workflows."
63
+ )
64
+ if open_tasks:
65
+ lines.append("Unfinished ww tasks, newest first:")
66
+ for task in open_tasks[:SESSION_TASK_LIMIT]:
67
+ lines.append(task_line(task, root, ww))
68
+ interruption = interruptions.get(task.task_id)
69
+ if interruption is not None:
70
+ lines.append(" " + interruption_notice(interruption, task.task_id))
71
+ elif task.agent_step_in_progress and not compacted:
72
+ lines.append(" " + abrupt_end_notice(task))
73
+ if len(open_tasks) > SESSION_TASK_LIMIT:
74
+ lines.append(
75
+ f"… and {len(open_tasks) - SESSION_TASK_LIMIT} more; "
76
+ f"`{ww} status <task-id>` shows one."
77
+ )
78
+ if open_tasks or skipped:
79
+ lines.append(f"`{ww} discover` lists every unfinished task.")
80
+ if unreadable:
81
+ lines.append(unreadable_notice(unreadable))
82
+ return "\n".join(lines) + "\n"
83
+
84
+
85
+ def unreadable_notice(unreadable: tuple[UnreadableTask, ...]) -> str:
86
+ """One line naming the tasks ww cannot read, without their errors."""
87
+ names = ", ".join(task.task_id for task in unreadable)
88
+ return (
89
+ f"ww cannot read the state of {names}; other tasks and new work are "
90
+ f"unaffected. `{ww_command()} discover` shows why; ask the operator "
91
+ "before touching them."
92
+ )
93
+
94
+
95
+ def task_line(task: OpenTask, root: Path, ww: str) -> str:
96
+ """One unfinished task with its step, state, workspace and resume commands."""
97
+ step = task.label
98
+ if task.operator_reason is not None:
99
+ state = "awaiting the operator: " + _OPERATOR_REASONS.get(
100
+ task.operator_reason, task.operator_reason
101
+ )
102
+ else:
103
+ state = (task.item_status or task.run_status).replace("_", " ")
104
+ parts = [f"- {task.task_id} ({task.workflow}, {task.agent}) {step}: {state}"]
105
+ parts.append(f"in {display_workspace(task.workspace, root)}")
106
+ parts.append(f"resume: `{ww} instruction {task.task_id} --role manager`")
107
+ if task.agent_step_in_progress and task.run_id:
108
+ parts.append(
109
+ f"worker: `{ww} instruction {task.task_id} --run {task.run_id} "
110
+ "--role worker`"
111
+ )
112
+ return " · ".join(parts)
113
+
114
+
115
+ def stop_reminder(tasks: tuple[OpenTask, ...]) -> str:
116
+ """Ask, once, that the open step be closed before the agent stops."""
117
+ ww = ww_command()
118
+ listed = tasks[:STOP_TASK_LIMIT]
119
+ names = "; ".join(f"{task.task_id} step `{task.label}`" for task in listed)
120
+ example = listed[0].task_id
121
+ return (
122
+ f"ww: {names} is still in progress. If the work is done, record it with "
123
+ f"`{ww} complete {example} --role worker ...` as its instruction shows; if "
124
+ f"it cannot finish, run `{ww} fail {example} --role worker "
125
+ f'--error "<reason>"`. '
126
+ "A manager waiting on a worker, or a deliberate pause, may simply stop "
127
+ "again: ww reminds only once."
128
+ )
129
+
130
+
131
+ def interruption_notice(interruption: Interruption, task_id: str) -> str:
132
+ """Tell whoever picks the task up that its last session stopped short."""
133
+ ww = ww_command()
134
+ who = interruption.agent + (
135
+ f", {interruption.reason}" if interruption.reason else ""
136
+ )
137
+ step = interruption.step or interruption.item_name or "its step"
138
+ if interruption.in_conversation:
139
+ lead = (
140
+ f"Interrupted: the previous session ({who}) stopped at {interruption.at} "
141
+ f"while `{step}` (attempt {interruption.attempt}) was talking with the "
142
+ "operator. "
143
+ )
144
+ if interruption.recovered_entries:
145
+ return lead + (
146
+ f"{interruption.recovered_entries} entries of the conversation "
147
+ "were recovered from the session transcript; read them on the "
148
+ "step's page and continue from the last unanswered point."
149
+ )
150
+ return lead + (
151
+ "The conversation was not recorded; ask the operator where you were."
152
+ )
153
+ return (
154
+ f"Interrupted: the previous session ({who}) stopped at {interruption.at} "
155
+ f"during `{step}` (attempt {interruption.attempt}). Before continuing, check "
156
+ "`git status` in the task workspace, review the diff and ww's recorded "
157
+ f"commits (`{ww} extension ww/git commits {task_id}`), and compare them "
158
+ "with the step's requirements; then complete, continue, or fail the step."
159
+ )
160
+
161
+
162
+ def abrupt_end_notice(task: OpenTask) -> str:
163
+ """Tell whoever picks the task up that its last session left no trace.
164
+
165
+ The ``interrupt`` hook never runs when a tab is closed, the agent crashes
166
+ or is killed, so the step is still in progress with no marker.
167
+ """
168
+ lead = (
169
+ f"Left in progress at {task.updated_at} by {task.agent} with no recorded "
170
+ "end, probably a closed session: "
171
+ )
172
+ if task.in_conversation:
173
+ return (
174
+ lead + "the step's page shows the conversation so far; pick it up at "
175
+ "the last unanswered question."
176
+ )
177
+ return (
178
+ lead + f"check its page (`{ww_command()} instruction {task.task_id} "
179
+ "--role manager`) before continuing."
180
+ )
181
+
182
+
183
+ def display_workspace(path: Path, root: Path) -> str:
184
+ """A workspace relative to the root, or "the root" itself."""
185
+ try:
186
+ relative = path.relative_to(root.resolve())
187
+ except ValueError:
188
+ return str(path)
189
+ return str(relative) if relative.parts else "the root"
190
+
191
+
192
+ def recent_interruptions_pointer(count: int, days: int) -> str | None:
193
+ """One line for ``discover`` and ``lookup``, only when there is news.
194
+
195
+ Those entry commands are what an agent without hooks runs first, so the
196
+ line is the whole of their "first start" check; the details live in
197
+ ``ww interrupted`` and on each task's own commands.
198
+ """
199
+ if not count:
200
+ return None
201
+ tasks = "task was" if count == 1 else "tasks were"
202
+ window = "day" if days == 1 else f"{days} days"
203
+ return (
204
+ f"{count} {tasks} interrupted in the last {window}; "
205
+ f"run `{ww_command()} interrupted` before starting new work."
206
+ )
ww/hooks/records.py ADDED
@@ -0,0 +1,209 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ """The two small per-task records agent hooks keep beside the interaction log.
3
+
4
+ ``interrupted.json`` marks a task whose agent session ended, or was
5
+ interrupted, while one of its agent-owned steps was in progress. It holds
6
+ one record, overwritten by the next interruption, and is cleared once that
7
+ step's attempt completes or fails, never merely because a notice showed it:
8
+ a compaction or a new session between showing and acting must not lose it.
9
+
10
+ ``stop-reminders.json`` lists the step attempts ww already reminded an agent
11
+ about when it stopped, so the reminder is given once and the next stop is
12
+ always allowed.
13
+
14
+ Both belong to the task and are forgotten when the task is reset.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ from dataclasses import asdict, dataclass
21
+ from datetime import datetime, timedelta, timezone
22
+ from pathlib import Path
23
+
24
+ from ww.errors import StateError
25
+ from ww.storage import Storage
26
+ from ww.storage_adapters import TaskStorageAdapter
27
+
28
+ INTERRUPTED_FILE = "interrupted.json"
29
+ REMINDERS_FILE = "stop-reminders.json"
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class Interruption:
34
+ """What a session was doing when it stopped short."""
35
+
36
+ at: str
37
+ run_id: str | None
38
+ step: str | None
39
+ item_id: str
40
+ # ``step`` is how messages name the work, a hook as "<name> (a hook of
41
+ # <step>)"; ``item_name`` is the plan item's own name.
42
+ item_name: str | None
43
+ attempt: int
44
+ agent: str
45
+ # The agent's own word for why the session ended, when it gives one.
46
+ reason: str | None = None
47
+ # The step was talking with the operator when the session ended.
48
+ in_conversation: bool = False
49
+ # How many entries of that conversation the hook recovered from the
50
+ # session's transcript into the interaction record.
51
+ recovered_entries: int = 0
52
+
53
+ @property
54
+ def moment(self) -> datetime | None:
55
+ try:
56
+ return datetime.fromisoformat(self.at.replace("Z", "+00:00"))
57
+ except ValueError:
58
+ return None
59
+
60
+ def to_dict(self) -> dict[str, object]:
61
+ return asdict(self)
62
+
63
+
64
+ class HookRecords:
65
+ """Read and write the hook records of the tasks in one project root."""
66
+
67
+ def __init__(self, storage: Storage, tasks: TaskStorageAdapter) -> None:
68
+ self.storage = storage
69
+ self.tasks = tasks
70
+
71
+ def _directory(self, task_id: str) -> Path:
72
+ return self.storage.runtime_path / "tasks" / task_id
73
+
74
+ # Interruptions
75
+
76
+ def mark_interrupted(self, task_id: str, record: Interruption) -> None:
77
+ path = self._directory(task_id) / INTERRUPTED_FILE
78
+ path.parent.mkdir(parents=True, exist_ok=True)
79
+ self.storage.locks.atomic_write(
80
+ path, json.dumps(record.to_dict(), indent=2) + "\n"
81
+ )
82
+
83
+ def interruption(self, task_id: str) -> Interruption | None:
84
+ """The task's interruption, or ``None`` once its attempt has ended.
85
+
86
+ A record whose attempt completed or failed, or was superseded by a
87
+ later attempt, is removed here: this is the one place that decides
88
+ whether the notice still applies.
89
+ """
90
+ path = self._directory(task_id) / INTERRUPTED_FILE
91
+ record = _read_interruption(path)
92
+ if record is None:
93
+ return None
94
+ if self._attempt_ended(task_id, record):
95
+ path.unlink(missing_ok=True)
96
+ return None
97
+ return record
98
+
99
+ def interruptions(self) -> tuple[tuple[str, Interruption], ...]:
100
+ """Every task still marked as interrupted, newest first."""
101
+ root = self.storage.runtime_path / "tasks"
102
+ if not root.is_dir():
103
+ return ()
104
+ found = []
105
+ for path in root.rglob(INTERRUPTED_FILE):
106
+ task_id = path.parent.relative_to(root).as_posix()
107
+ try:
108
+ record = self.interruption(task_id)
109
+ except StateError:
110
+ # open_work() reports the unreadable task; skip it here.
111
+ continue
112
+ if record is not None:
113
+ found.append((task_id, record))
114
+ return tuple(sorted(found, key=lambda entry: entry[1].at, reverse=True))
115
+
116
+ def recent(self, days: int) -> tuple[tuple[str, Interruption], ...]:
117
+ """The interruptions of the last ``days`` days, newest first."""
118
+ horizon = datetime.now(timezone.utc) - timedelta(days=days)
119
+ return tuple(
120
+ (task_id, record)
121
+ for task_id, record in self.interruptions()
122
+ if record.moment is not None and record.moment >= horizon
123
+ )
124
+
125
+ def _attempt_ended(self, task_id: str, record: Interruption) -> bool:
126
+ runs, _, _ = self.tasks.read_task_record(task_id)
127
+ run = next((run for run in runs if run.run_id == record.run_id), None)
128
+ if run is None:
129
+ return False
130
+ execution = next(
131
+ (
132
+ entry
133
+ for entry in run.state.item_executions
134
+ if entry.plan_item_id == record.item_id
135
+ ),
136
+ None,
137
+ )
138
+ if execution is None:
139
+ return False
140
+ return execution.status in {"completed", "failed"} or (
141
+ execution.attempts > record.attempt
142
+ )
143
+
144
+ # Stop reminders
145
+
146
+ def claim_reminder(self, task_id: str, key: str) -> bool:
147
+ """Record the reminder for ``key``; ``False`` when it was already given.
148
+
149
+ Agents may run the same hook twice at once (Claude Code runs matching
150
+ hooks in parallel), so checking and recording happen under one lock of
151
+ their own: exactly one call reminds. It is not the task lock, which a
152
+ long-running command may hold for minutes.
153
+ """
154
+ path = self._directory(task_id) / REMINDERS_FILE
155
+ with self.storage.locks.lock(path, purpose="stop reminders"):
156
+ keys = self._reminders(task_id)
157
+ if key in keys:
158
+ return False
159
+ path.parent.mkdir(parents=True, exist_ok=True)
160
+ self.storage.locks.atomic_write(
161
+ path, json.dumps([*keys, key], indent=2) + "\n"
162
+ )
163
+ return True
164
+
165
+ def _reminders(self, task_id: str) -> list[str]:
166
+ path = self._directory(task_id) / REMINDERS_FILE
167
+ try:
168
+ value = json.loads(path.read_text(encoding="utf-8"))
169
+ except (OSError, json.JSONDecodeError):
170
+ return []
171
+ return (
172
+ [entry for entry in value if isinstance(entry, str)]
173
+ if isinstance(value, list)
174
+ else []
175
+ )
176
+
177
+ def remove(self, task_id: str) -> None:
178
+ """Forget both records, as part of resetting the task."""
179
+ for name in (INTERRUPTED_FILE, REMINDERS_FILE):
180
+ (self._directory(task_id) / name).unlink(missing_ok=True)
181
+
182
+
183
+ def reminder_key(run_id: str | None, item_id: str, attempt: int) -> str:
184
+ """One step attempt, which ww reminds an agent about at most once."""
185
+ return f"{run_id or '-'}:{item_id}:{attempt}"
186
+
187
+
188
+ def _read_interruption(path: Path) -> Interruption | None:
189
+ try:
190
+ value = json.loads(path.read_text(encoding="utf-8"))
191
+ except (OSError, json.JSONDecodeError):
192
+ return None
193
+ if not isinstance(value, dict):
194
+ return None
195
+ try:
196
+ return Interruption(
197
+ at=str(value["at"]),
198
+ run_id=value.get("run_id"),
199
+ step=value.get("step"),
200
+ item_id=str(value["item_id"]),
201
+ item_name=value.get("item_name"),
202
+ attempt=int(value.get("attempt", 0)),
203
+ agent=str(value.get("agent", "")),
204
+ reason=value.get("reason"),
205
+ in_conversation=bool(value.get("in_conversation", False)),
206
+ recovered_entries=int(value.get("recovered_entries", 0)),
207
+ )
208
+ except (KeyError, TypeError, ValueError):
209
+ return None
ww/hooks/runtime.py ADDED
@@ -0,0 +1,266 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ """What ww answers when an agent calls one of its hooks.
3
+
4
+ The hooks are gentle by design: they add a few lines of context or remind
5
+ once, and nothing is ever blocked.
6
+
7
+ - ``session-start`` prints a reminder that ww coordinates work here, and the
8
+ recently updated unfinished tasks with the commands that resume them,
9
+ unless ``agent_hooks.check_unfinished`` switches that scan off.
10
+ - ``stop`` asks the agent, once per step attempt, to record an agent-owned
11
+ step that is still in progress; the next stop is always allowed.
12
+ - ``interrupt`` records, without answering, that a session ended while such
13
+ a step was in progress, so the next session is told to check the work.
14
+ When the step was talking with the operator, it first recovers what the
15
+ two sides said from the session's transcript into the interaction record.
16
+
17
+ An adapter reads the agent's payload and renders the answer; everything else
18
+ is decided here, from persisted state alone.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ from dataclasses import dataclass, replace
25
+ from datetime import datetime, timedelta, timezone
26
+ from pathlib import Path
27
+
28
+ from ww.config_files import WORKFLOWS_FILE
29
+ from ww.interactions import RECOVERED, InteractionLog
30
+ from ww.open_work import OpenTask, open_work, tasks_for_session
31
+ from ww.project_config import AgentHooks
32
+ from ww.storage import Storage
33
+
34
+ from .agents import HookAgent, HookEvent, HookPayload
35
+ from .notices import session_context, stop_reminder
36
+ from .records import HookRecords, Interruption, reminder_key
37
+ from .transcripts import recover_conversation
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class HookAnswer:
42
+ """What the hook prints, and a word for the audit log about why."""
43
+
44
+ text: str
45
+ decision: str
46
+
47
+
48
+ ALLOW = HookAnswer("", "allowed")
49
+
50
+
51
+ def answer_hook(
52
+ storage: Storage,
53
+ agent: HookAgent,
54
+ event: HookEvent,
55
+ raw_payload: str,
56
+ *,
57
+ settings: AgentHooks,
58
+ on_request: bool = False,
59
+ ) -> HookAnswer:
60
+ """ww's answer to one hook call; the caller contains every error.
61
+
62
+ ``on_request`` is the project's ``"enabled": "on_request"``: the session
63
+ is told that ww is used only when the user asks for it. Stop reminders
64
+ are unchanged, since they concern tasks already open. ``settings`` is
65
+ the project's ``agent_hooks``, which only ``session-start`` reads.
66
+ """
67
+ payload = agent.parse(event, _payload(raw_payload))
68
+ records = HookRecords(storage, storage.task_persistence)
69
+ if event == "session-start":
70
+ return _session_start(
71
+ storage, agent, payload, records, settings, on_request=on_request
72
+ )
73
+ # An unreadable task has no step anyone can close, so stop and interrupt
74
+ # consider only the tasks that could be read.
75
+ tasks = open_work(storage.task_persistence, storage.root).tasks
76
+ if event == "stop" and not payload.interrupted:
77
+ return _stop(agent, payload, records, tasks)
78
+ return _interrupt(agent, payload, records, tasks)
79
+
80
+
81
+ def _session_start(
82
+ storage: Storage,
83
+ agent: HookAgent,
84
+ payload: HookPayload,
85
+ records: HookRecords,
86
+ settings: AgentHooks,
87
+ *,
88
+ on_request: bool,
89
+ ) -> HookAnswer:
90
+ if not payload.wants_context:
91
+ return HookAnswer("", "no context needed")
92
+ compacted = payload.source == "compact"
93
+ if not settings.check_unfinished:
94
+ text = session_context(
95
+ (), {}, storage.root, compacted=compacted, on_request=on_request
96
+ )
97
+ return HookAnswer(
98
+ agent.context_reply(text.rstrip("\n")) + "\n",
99
+ "context without the unfinished-task scan",
100
+ )
101
+ horizon = datetime.now(timezone.utc) - timedelta(days=settings.recent_days)
102
+ work = open_work(storage.task_persistence, storage.root, since=horizon)
103
+ interruptions = {
104
+ task.task_id: record
105
+ for task in work.tasks
106
+ if (record := records.interruption(task.task_id)) is not None
107
+ }
108
+ text = session_context(
109
+ work.tasks,
110
+ interruptions,
111
+ storage.root,
112
+ compacted=compacted,
113
+ unreadable=work.unreadable,
114
+ on_request=on_request,
115
+ skipped=work.skipped,
116
+ )
117
+ return HookAnswer(
118
+ agent.context_reply(text.rstrip("\n")) + "\n",
119
+ f"context with {len(work.tasks)} unfinished task(s)",
120
+ )
121
+
122
+
123
+ def _stop(
124
+ agent: HookAgent,
125
+ payload: HookPayload,
126
+ records: HookRecords,
127
+ tasks: tuple[OpenTask, ...],
128
+ ) -> HookAnswer:
129
+ if payload.continued:
130
+ return replace(ALLOW, decision="allowed: the agent already continued once")
131
+ # A manager waiting on a worker is not the one to close the step: its own
132
+ # stop skips delegated steps, and the worker's stop reminds instead. A
133
+ # step in conversation with the operator stops to hear them.
134
+ if not payload.from_worker and any(
135
+ task.waiting_on_another(tasks)
136
+ for task in tasks_for_session(
137
+ tasks, records.storage.root, payload.directory, agent.name
138
+ )
139
+ ):
140
+ return replace(
141
+ ALLOW, decision="allowed: the manager is waiting on a child or worker"
142
+ )
143
+ working = tasks_for_session(
144
+ tuple(
145
+ task
146
+ for task in tasks
147
+ if task.agent_step_in_progress
148
+ and not task.in_conversation
149
+ and (payload.from_worker or not task.delegated)
150
+ ),
151
+ records.storage.root,
152
+ payload.directory,
153
+ agent.name,
154
+ )
155
+ pending = tuple(
156
+ task
157
+ for task in working
158
+ if task.item_id is not None and records.claim_reminder(task.task_id, _key(task))
159
+ )
160
+ if not pending:
161
+ return ALLOW
162
+ return HookAnswer(
163
+ agent.continue_reply(stop_reminder(pending)) + "\n",
164
+ "reminded: " + ", ".join(task.task_id for task in pending),
165
+ )
166
+
167
+
168
+ def _interrupt(
169
+ agent: HookAgent,
170
+ payload: HookPayload,
171
+ records: HookRecords,
172
+ tasks: tuple[OpenTask, ...],
173
+ ) -> HookAnswer:
174
+ working = tasks_for_session(
175
+ tuple(task for task in tasks if task.agent_step_in_progress),
176
+ records.storage.root,
177
+ payload.directory,
178
+ agent.name,
179
+ )
180
+ if not working:
181
+ return HookAnswer("", "no step in progress to mark")
182
+ at = (
183
+ datetime.now(timezone.utc)
184
+ .replace(microsecond=0)
185
+ .isoformat()
186
+ .replace("+00:00", "Z")
187
+ )
188
+ for task in working:
189
+ assert task.item_id is not None
190
+ recovered = (
191
+ _recover(agent, payload, records.storage, task, at)
192
+ if task.in_conversation
193
+ else 0
194
+ )
195
+ records.mark_interrupted(
196
+ task.task_id,
197
+ Interruption(
198
+ at=at,
199
+ run_id=task.run_id,
200
+ step=task.label,
201
+ item_id=task.item_id,
202
+ item_name=task.item_name,
203
+ attempt=task.attempt,
204
+ agent=agent.name,
205
+ reason=payload.reason,
206
+ in_conversation=task.in_conversation,
207
+ recovered_entries=recovered,
208
+ ),
209
+ )
210
+ return HookAnswer(
211
+ "", "marked interrupted: " + ", ".join(task.task_id for task in working)
212
+ )
213
+
214
+
215
+ def _recover(
216
+ agent: HookAgent,
217
+ payload: HookPayload,
218
+ storage: Storage,
219
+ task: OpenTask,
220
+ at: str,
221
+ ) -> int:
222
+ """Append the conversation the session's transcript holds; its size.
223
+
224
+ Only what was said since the step's attempt started is taken. The
225
+ task lock is not taken: the session that held the conversation is the
226
+ one ending, and the hook has seconds, not minutes. Any failure
227
+ recovers nothing, so the hook never fails over it.
228
+ """
229
+ if payload.transcript_path is None:
230
+ return 0
231
+ try:
232
+ since = datetime.fromisoformat(
233
+ (task.started_at or task.updated_at).replace("Z", "+00:00")
234
+ )
235
+ entries = list(recover_conversation(agent.name, payload.transcript_path, since))
236
+ if entries and entries[-1][0] != "agent" and payload.last_agent_message:
237
+ entries.append(("agent", payload.last_agent_message.strip()))
238
+ InteractionLog(storage).append_entries(
239
+ task.task_id,
240
+ [(speaker + RECOVERED, text) for speaker, text in entries],
241
+ run_id=task.run_id,
242
+ step=task.item_name or task.label,
243
+ item_id=task.work_item_id,
244
+ at=at,
245
+ )
246
+ except Exception: # noqa: BLE001 - a hook must never fail over recovery
247
+ return 0
248
+ return len(entries)
249
+
250
+
251
+ def _key(task: OpenTask) -> str:
252
+ assert task.item_id is not None
253
+ return reminder_key(task.run_id, task.item_id, task.attempt)
254
+
255
+
256
+ def _payload(raw: str) -> dict[str, object]:
257
+ try:
258
+ value = json.loads(raw) if raw.strip() else {}
259
+ except json.JSONDecodeError:
260
+ return {}
261
+ return value if isinstance(value, dict) else {}
262
+
263
+
264
+ def is_project_root(root: Path) -> bool:
265
+ """Only a directory holding the repo workflow file is a ww root."""
266
+ return (root / WORKFLOWS_FILE).is_file()