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/cli/main.py ADDED
@@ -0,0 +1,1768 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ """Entry point: parse arguments, run one service command, render and log it."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import contextlib
8
+ import io
9
+ import json
10
+ import os
11
+ import subprocess
12
+ import sys
13
+ import uuid
14
+ import webbrowser
15
+ from collections.abc import Callable
16
+ from dataclasses import dataclass, replace
17
+ from datetime import datetime, timezone
18
+ from pathlib import Path
19
+ from typing import cast
20
+
21
+ from ww import rule_conversion, rule_writes, setup_apply, workflow_update
22
+ from ww.config import load_configuration
23
+ from ww.config.composition import compose_configuration
24
+ from ww.config_files import (
25
+ SETTINGS_FILE,
26
+ WORKFLOWS_FILE,
27
+ display_path,
28
+ staged_files,
29
+ )
30
+ from ww.config_writes import FileWrite
31
+ from ww.defaults import PROJECT_LAUNCHER
32
+ from ww.design_docs import read_design_document
33
+ from ww.errors import StateError, WwError
34
+ from ww.executable import printed_executable, ww_command
35
+ from ww.extensions import ExtensionContext, ExtensionRegistry
36
+ from ww.hooks import (
37
+ HOOK_EVENTS,
38
+ HookAgent,
39
+ answer_hook,
40
+ hook_agent,
41
+ hook_snippet,
42
+ hooks_file,
43
+ install_hooks,
44
+ is_project_root,
45
+ registered_elsewhere,
46
+ uninstall_hooks,
47
+ )
48
+ from ww.hooks.notices import interruption_notice
49
+ from ww.inspect import inspect_checkout, render_markdown
50
+ from ww.instructions import Instruction
51
+ from ww.instructions.policy import manager_continues_itself
52
+ from ww.items import WorkItem
53
+ from ww.onboarding import Onboarding, render_onboarding
54
+ from ww.operator_ui import run_operator_page
55
+ from ww.operator_ui.server import operator_wait_seconds
56
+ from ww.output import (
57
+ render,
58
+ render_initialization,
59
+ render_initialization_welcome,
60
+ render_item_update,
61
+ render_plan,
62
+ render_reset,
63
+ render_status,
64
+ )
65
+ from ww.output_adapters.rule_pages import (
66
+ render_check_preview,
67
+ render_convert_preview,
68
+ render_decline_preview,
69
+ render_orphans,
70
+ render_revoke_preview,
71
+ render_revoked,
72
+ render_rule_view,
73
+ render_rules_listing,
74
+ )
75
+ from ww.plan import PlanCompilationOptions, compile_workflow_plan
76
+ from ww.project_config import compose_settings, load_project_config
77
+ from ww.rule_disputes import DisputeLog
78
+ from ww.rule_store import (
79
+ CheckSpec,
80
+ RuleAutomation,
81
+ RuleStore,
82
+ parse_command,
83
+ )
84
+ from ww.rule_verification import revoke_check
85
+ from ww.rule_views import declared_hashes, orphans, prune, rules_listing
86
+ from ww.service import WorkflowService
87
+ from ww.storage import Storage
88
+ from ww.upgrade import upgrade
89
+ from ww.workflow_config import (
90
+ ALL,
91
+ ALL_NAMES,
92
+ NameFilter,
93
+ RuleDefinition,
94
+ WorkflowConfiguration,
95
+ every_step,
96
+ )
97
+
98
+ from .audit import _log_record
99
+ from .catalogs import (
100
+ _catalog_agents,
101
+ _catalog_extensions,
102
+ _catalog_modes,
103
+ _catalog_projects,
104
+ _catalog_runtimes,
105
+ _catalog_workflows,
106
+ )
107
+ from .discover import render_discover
108
+ from .initialization import (
109
+ _finish_initialization,
110
+ _initialization_options,
111
+ _link_agent_instructions,
112
+ install_agent_hooks,
113
+ install_claude_permissions,
114
+ )
115
+ from .lookup import render_lookup
116
+ from .parser import _metadata_values, _named_values, _variables, build_parser
117
+ from .prompts import (
118
+ _confirm_force_next,
119
+ confirm_interrupted_retry,
120
+ confirm_operator,
121
+ )
122
+ from .updates import announce, render_updates
123
+
124
+ _MANAGER_ONLY_COMMANDS = frozenset({"start", "next"})
125
+ # Discovery, linting, and planning are side-effect free: no task state, artifacts,
126
+ # commands, or execution-log records are created.
127
+ _READ_ONLY_COMMANDS = frozenset(
128
+ {
129
+ "discover",
130
+ "docs",
131
+ "inspect",
132
+ "lookup",
133
+ "lint",
134
+ "plan",
135
+ "documents",
136
+ "interactions",
137
+ "updates",
138
+ "interrupted",
139
+ "check",
140
+ "rule",
141
+ "requirements",
142
+ }
143
+ )
144
+ # Commands whose stdout is consumed by a program rather than read, whether or
145
+ # not ``--json`` was passed. An update notice goes to stderr for these, so it
146
+ # never lands in the middle of a document someone is parsing.
147
+ _MACHINE_READABLE_COMMANDS = frozenset(
148
+ {
149
+ "add-child",
150
+ "add-item",
151
+ "agents",
152
+ "artifacts",
153
+ "documents",
154
+ "extension",
155
+ "extensions",
156
+ "item",
157
+ "items",
158
+ "metadata",
159
+ "modes",
160
+ "projects",
161
+ "remove-item",
162
+ "runtimes",
163
+ "update-child",
164
+ "workflows",
165
+ }
166
+ )
167
+
168
+
169
+ @dataclass(frozen=True)
170
+ class _Context:
171
+ args: argparse.Namespace
172
+ storage: Storage
173
+ extensions: ExtensionRegistry
174
+ service: WorkflowService
175
+
176
+ @property
177
+ def task_id(self) -> str:
178
+ return cast(str, self.args.task_id)
179
+
180
+
181
+ @dataclass(frozen=True)
182
+ class _Outcome:
183
+ """What one command printed and what its audit record should say."""
184
+
185
+ text: str
186
+ workflow: str | None = None
187
+ task_id: str | None = None
188
+ error: str | None = None
189
+ exit_code: int = 0
190
+
191
+
192
+ def _json(value: object) -> str:
193
+ return json.dumps(value, indent=2) + "\n"
194
+
195
+
196
+ def _instruction_outcome(
197
+ instruction: Instruction, json_output: bool, *, completing: bool = False
198
+ ) -> _Outcome:
199
+ failed = instruction.status in {"failed", "interrupted"}
200
+ repairing = completing and instruction.handler_repair is not None
201
+ # A rejected completion exits non-zero too, so the worker reads the page;
202
+ # showing the fix page again later is no failure of that command.
203
+ rejected = completing and instruction.fix_required is not None and not failed
204
+ return _Outcome(
205
+ render(instruction, json_output) + "\n",
206
+ instruction.workflow,
207
+ instruction.task_id,
208
+ error=(
209
+ instruction.error
210
+ if failed or repairing
211
+ else _rejection(instruction)
212
+ if rejected
213
+ else None
214
+ ),
215
+ exit_code=1 if failed or rejected or repairing else 0,
216
+ )
217
+
218
+
219
+ def _rejection(instruction: Instruction) -> str:
220
+ fix = instruction.fix_required
221
+ assert fix is not None
222
+ return (
223
+ "completion rejected: "
224
+ + ", ".join(failure.id for failure in fix.failures)
225
+ + " failed"
226
+ )
227
+
228
+
229
+ def _init(context: _Context) -> _Outcome:
230
+ welcome = render_initialization_welcome(context.args.json_output)
231
+ if welcome:
232
+ if (
233
+ sys.stdout.isatty()
234
+ and "NO_COLOR" not in os.environ
235
+ and os.environ.get("TERM") != "dumb"
236
+ ):
237
+ welcome = f"\033[36m{welcome}\033[0m"
238
+ sys.stdout.write(welcome)
239
+ sys.stdout.flush()
240
+ print(f"Setting up ww in: {context.storage.root}\n", flush=True)
241
+ workflows, project_config, ignore_runtime, skill_installs = _initialization_options(
242
+ context.storage, context.args
243
+ )
244
+ result = context.service.initialize(
245
+ workflows=workflows,
246
+ project_config=project_config,
247
+ ignore_runtime=ignore_runtime,
248
+ skill_installs=skill_installs,
249
+ )
250
+ if context.args.link_instructions:
251
+ result = _link_agent_instructions(context.storage, result)
252
+ result = install_agent_hooks(context.storage, context.args, result)
253
+ result = install_claude_permissions(context.storage, context.args, result)
254
+ result = _finish_initialization(
255
+ context.storage,
256
+ result,
257
+ shown=not context.args.json_output,
258
+ force=context.args.force,
259
+ )
260
+ return _Outcome(render_initialization(result, context.args.json_output) + "\n")
261
+
262
+
263
+ def _plan(context: _Context) -> _Outcome:
264
+ args = context.args
265
+ plan = compile_workflow_plan(
266
+ load_configuration(context.storage.config_path, context.extensions),
267
+ context.storage.root,
268
+ args.workflow,
269
+ args.agent,
270
+ args.task_id,
271
+ context.extensions,
272
+ PlanCompilationOptions(task_id=args.task_id, project=args.project),
273
+ context.extensions.config,
274
+ )
275
+ rendered = render_plan(plan, args.json_output)
276
+ if not args.json_output:
277
+ rendered += f"\n{_configuration_files(context.storage, context.extensions)}"
278
+ return _Outcome(rendered)
279
+
280
+
281
+ def _lint(context: _Context) -> _Outcome:
282
+ configuration = load_configuration(context.storage.config_path, context.extensions)
283
+ for project in context.extensions.config.projects:
284
+ context.extensions.validate_configuration(project.name)
285
+ notices = "".join(
286
+ f"Notice: {notice}\n"
287
+ for notice in compose_configuration(context.storage.config_path).notices
288
+ )
289
+ return _Outcome(
290
+ f"{WORKFLOWS_FILE} is valid.\n"
291
+ f"{_configuration_files(context.storage, context.extensions)}"
292
+ f"{notices}"
293
+ f"{_launcher_warning(context.storage.root)}"
294
+ f"{_rules_summary(configuration)}"
295
+ f"{_rule_store_summary(RuleStore(context.storage.root), configuration)}"
296
+ f"{_unscriptized_warning(RuleStore(context.storage.root), configuration)}"
297
+ f"{_disputes_summary(DisputeLog(context.storage.root))}"
298
+ )
299
+
300
+
301
+ def _launcher_warning(root: Path) -> str:
302
+ """Warn when ./ww is not the launcher this ww writes.
303
+
304
+ The launcher is ww-owned; one an older ww wrote can read old
305
+ configuration names and run the wrong binary, and ``init`` rewrites it.
306
+ """
307
+ launcher = root / "ww"
308
+ if not launcher.is_file():
309
+ return ""
310
+ if launcher.read_text(encoding="utf-8") == PROJECT_LAUNCHER:
311
+ return ""
312
+ return (
313
+ "Warning: ./ww differs from the current launcher; run "
314
+ f"`{ww_command()} init` to rewrite it.\n"
315
+ )
316
+
317
+
318
+ def _disputes_summary(log: DisputeLog) -> str:
319
+ """Rules and checks workers have disputed, most disputed first.
320
+
321
+ A check disputed again and again is worth the operator's look: its
322
+ wording or its command may be wrong.
323
+ """
324
+ entries = log.load()
325
+ counts: dict[str, int] = {}
326
+ last: dict[str, str] = {}
327
+ for entry in entries:
328
+ counts[entry.check] = counts.get(entry.check, 0) + 1
329
+ last[entry.check] = f"{entry.task_id} {entry.step}"
330
+ return "".join(
331
+ f"Disputed {check}: {count} time{'s' if count != 1 else ''}, last in "
332
+ f"{last[check]}\n"
333
+ for check, count in sorted(counts.items(), key=lambda pair: -pair[1])
334
+ )
335
+
336
+
337
+ def _rule_store_summary(store: RuleStore, configuration: WorkflowConfiguration) -> str:
338
+ """What ``ww-rule-automation.json`` holds that needs the operator's eye.
339
+
340
+ An entry is an orphan when no rule of the composed configuration has its
341
+ wording any more (``ww rules prune`` deletes those). Lint removes nothing.
342
+ """
343
+ if not store.exists():
344
+ return ""
345
+ automation = store.load()
346
+ hashes = declared_hashes(configuration)
347
+ lines = [
348
+ f"Rule store: {len(automation.rules)} rule"
349
+ f"{'s' if len(automation.rules) != 1 else ''}, {len(automation.checks)} "
350
+ f"check{'s' if len(automation.checks) != 1 else ''}\n"
351
+ ]
352
+ for text_hash, entry in automation.rules.items():
353
+ if text_hash not in hashes:
354
+ lines.append(f"Orphan rule {text_hash[:12]}: {entry.text}\n")
355
+ return "".join(lines)
356
+
357
+
358
+ def _unscriptized_warning(
359
+ store: RuleStore, configuration: WorkflowConfiguration
360
+ ) -> str:
361
+ """Name the declared rules no check covers yet; verifiers judge them.
362
+
363
+ A warning only: ``ww-scriptize-rules`` builds checks for them, and is
364
+ suggested only while it is switched on.
365
+ """
366
+ rules = rule_conversion.unscriptized_rules(configuration, store.load())
367
+ if not rules:
368
+ return ""
369
+ warning = (
370
+ f"Warning: {len(rules)} rule{'s have' if len(rules) != 1 else ' has'} no "
371
+ "check yet (" + ", ".join(rule.id for rule in rules) + ")"
372
+ )
373
+ if rule_conversion.SCRIPTIZE_WORKFLOW not in configuration.workflows_by_name:
374
+ return f"{warning}.\n"
375
+ return (
376
+ f"{warning}; `{rule_conversion.SCRIPTIZE_WORKFLOW}` builds checks for them.\n"
377
+ )
378
+
379
+
380
+ def _rules_summary(configuration: WorkflowConfiguration) -> str:
381
+ """``Rules: N groups, M rules``, when the configuration declares any.
382
+
383
+ A rule counts once however many groups or steps reach it.
384
+ """
385
+ groups = len(configuration.rule_groups)
386
+ rules = {rule.id for group in configuration.rule_groups for rule in group.rules} | {
387
+ entry.id
388
+ for step in every_step(configuration)
389
+ for entry in step.rules
390
+ if isinstance(entry, RuleDefinition)
391
+ }
392
+ if not groups and not rules:
393
+ return ""
394
+ return (
395
+ f"Rules: {groups} group{'s' if groups != 1 else ''}, "
396
+ f"{len(rules)} rule{'s' if len(rules) != 1 else ''}\n"
397
+ )
398
+
399
+
400
+ def _configuration_files(storage: Storage, extensions: ExtensionRegistry) -> str:
401
+ """The configuration files this project reads, from user to local.
402
+
403
+ A configured project's own settings files follow on their own line, so an
404
+ extension setting that applies only there is visible where it was read.
405
+ """
406
+ workflows = compose_configuration(storage.config_path).sources
407
+ _, settings = compose_settings(storage.project_config_path)
408
+ files = (
409
+ *workflows,
410
+ *(display_path(path, storage.root) for path in settings),
411
+ )
412
+ rendered = "Configuration files: " + ", ".join(files) + "\n"
413
+ for project in extensions.config.projects:
414
+ sources = extensions.project_settings(project.name).sources
415
+ if sources:
416
+ rendered += (
417
+ f"Project {project.name} extension settings: "
418
+ + ", ".join(display_path(path, storage.root) for path in sources)
419
+ + "\n"
420
+ )
421
+ return rendered
422
+
423
+
424
+ def _start(context: _Context) -> _Outcome:
425
+ args = context.args
426
+ return _instruction_outcome(
427
+ context.service.start(
428
+ args.workflow,
429
+ args.task_id,
430
+ tuple(args.modes),
431
+ args.agent,
432
+ args.model,
433
+ args.reasoning,
434
+ args.workflow_runtime,
435
+ args.requirements,
436
+ caller_role=args.role,
437
+ branch_naming_strategy=args.branch_naming_strategy,
438
+ project=args.project,
439
+ fresh_items=args.fresh_items,
440
+ ),
441
+ args.json_output,
442
+ )
443
+
444
+
445
+ def _confirmation(assume_yes: bool) -> str:
446
+ """How a gated choice was confirmed, for the audit record."""
447
+ return "--yes" if assume_yes else "operator at a terminal"
448
+
449
+
450
+ def _next(context: _Context) -> _Outcome:
451
+ args = context.args
452
+ return _with_interruption(
453
+ context,
454
+ _instruction_outcome(
455
+ context.service.next(
456
+ context.task_id,
457
+ args.model,
458
+ args.reasoning,
459
+ args.force,
460
+ retry=args.retry,
461
+ force_reason=args.force_reason,
462
+ outcome=args.outcome,
463
+ selected_agent=args.selected_agent,
464
+ caller_role=args.role,
465
+ reassign=args.reassign,
466
+ replan=args.replan,
467
+ keep_plan=args.keep_plan,
468
+ ),
469
+ args.json_output,
470
+ ),
471
+ )
472
+
473
+
474
+ def _loop(context: _Context) -> _Outcome:
475
+ args = context.args
476
+ return _instruction_outcome(
477
+ context.service.loop(
478
+ context.task_id,
479
+ _variables(args.variable),
480
+ args.artifact,
481
+ _metadata_values(args.metadata),
482
+ selected_agent=args.selected_agent,
483
+ selected_model=args.selected_model,
484
+ selected_reasoning=args.selected_reasoning,
485
+ continue_loop=args.continue_loop,
486
+ summary_for_next=args.summary,
487
+ caller_role=args.role,
488
+ assignment=args.assignment,
489
+ ),
490
+ args.json_output,
491
+ completing=True,
492
+ )
493
+
494
+
495
+ def _check(context: _Context) -> _Outcome:
496
+ """Run the active step's checks now; exits 1 when any would fail."""
497
+ preview = context.service.check(context.task_id)
498
+ text = (
499
+ _json(preview.to_dict())
500
+ if context.args.json_output
501
+ else render_check_preview(preview)
502
+ )
503
+ failed = bool(preview.failures)
504
+ return _Outcome(
505
+ text,
506
+ None,
507
+ context.task_id,
508
+ error=(
509
+ "checks would fail: " + ", ".join(f.id for f in preview.failures)
510
+ if failed
511
+ else None
512
+ ),
513
+ exit_code=1 if failed else 0,
514
+ )
515
+
516
+
517
+ def _dispute(context: _Context) -> _Outcome:
518
+ args = context.args
519
+ return _instruction_outcome(
520
+ context.service.dispute(
521
+ context.task_id,
522
+ args.check_id,
523
+ args.reason,
524
+ caller_role=args.role,
525
+ assignment=args.assignment,
526
+ ),
527
+ args.json_output,
528
+ )
529
+
530
+
531
+ def _rule(context: _Context) -> _Outcome:
532
+ view = context.service.rule(context.task_id, context.args.rule_id)
533
+ return _Outcome(
534
+ _json(view.to_dict()) if context.args.json_output else render_rule_view(view),
535
+ None,
536
+ context.task_id,
537
+ )
538
+
539
+
540
+ def _rules(context: _Context) -> _Outcome:
541
+ """List the declared rules, or prune the store's orphan entries."""
542
+ args = context.args
543
+ configuration = load_configuration(context.storage.config_path, context.extensions)
544
+ if args.rules_action == "prune":
545
+ return _prune(context, configuration)
546
+ if args.rules_action == "revoke":
547
+ return _revoke(context)
548
+ if args.rules_action == "convert":
549
+ return _convert(context, configuration)
550
+ if args.rules_action == "decline":
551
+ return _decline(context, configuration)
552
+ if args.rules_action is not None:
553
+ return _rule_write(context, configuration)
554
+ settings = context.extensions.config
555
+ listing = replace(
556
+ rules_listing(
557
+ configuration,
558
+ context.storage.root,
559
+ DisputeLog(context.storage.root).load(),
560
+ RuleStore(context.storage.root).load(),
561
+ ),
562
+ check_guidance=settings.rule_check_guidance,
563
+ )
564
+ return _Outcome(
565
+ _json(listing.to_dict()) if args.json_output else render_rules_listing(listing)
566
+ )
567
+
568
+
569
+ def _rule_write(context: _Context, configuration: WorkflowConfiguration) -> _Outcome:
570
+ """Write a rule file or group, validated; nothing is committed."""
571
+ args = context.args
572
+ root = context.storage.root
573
+ project = rule_writes.RuleProject(
574
+ root,
575
+ context.storage.config_path,
576
+ configuration,
577
+ compose_configuration(context.storage.config_path),
578
+ context.extensions,
579
+ )
580
+ action = args.rules_action
581
+ if action == "add" and args.new_group is not None:
582
+ if args.group_name is not None or args.text is not None:
583
+ raise StateError(
584
+ "rules add takes either GROUP with --text, or --group with --dir"
585
+ )
586
+ if args.directory is None:
587
+ raise StateError("rules add --group needs --dir")
588
+ write = rule_writes.plan_add_group(
589
+ project,
590
+ args.new_group,
591
+ args.directory,
592
+ workflows=_filter_option(args.workflows, "--workflows"),
593
+ steps=_filter_option(args.steps, "--steps"),
594
+ )
595
+ elif action == "add":
596
+ if args.group_name is None or args.text is None:
597
+ raise StateError(
598
+ "rules add takes GROUP with --text, or --group NAME with --dir"
599
+ )
600
+ if (
601
+ args.directory is not None
602
+ or args.workflows is not None
603
+ or (args.steps is not None)
604
+ ):
605
+ raise StateError("--dir, --workflows and --steps go with --group")
606
+ write = rule_writes.plan_add_rule(
607
+ project,
608
+ args.group_name,
609
+ args.text,
610
+ paths=tuple(args.paths or ()),
611
+ check=rule_writes.check_mapping(
612
+ args.check_shell,
613
+ tuple(args.check_argv) if args.check_argv else None,
614
+ tuple(args.assertion) if args.assertion else None,
615
+ ),
616
+ stem=args.stem,
617
+ )
618
+ elif action == "edit":
619
+ write = rule_writes.plan_edit(
620
+ project,
621
+ RuleStore(root).load(),
622
+ args.rule_id,
623
+ text=args.text,
624
+ paths=tuple(args.paths) if args.paths is not None else None,
625
+ )
626
+ elif action == "move":
627
+ write = rule_writes.plan_move(project, args.rule_id, args.target_group)
628
+ elif action == "filter":
629
+ write = rule_writes.plan_filter(
630
+ project,
631
+ args.group_name,
632
+ workflows=_filter_option(args.workflows, "--workflows"),
633
+ steps=_filter_option(args.steps, "--steps"),
634
+ all_workflows=args.all_workflows,
635
+ all_steps=args.all_steps,
636
+ )
637
+ else:
638
+ write = rule_writes.plan_promote(
639
+ project, RuleStore(root).load(), args.check_name
640
+ )
641
+ return _Outcome(
642
+ rule_writes.apply_write(project, write, dry_run=args.dry_run).render()
643
+ )
644
+
645
+
646
+ def _filter_option(values: list[str] | None, option: str) -> NameFilter | None:
647
+ """``--workflows``/``--steps``: ``'*'`` alone for all, else the names given.
648
+
649
+ ``None`` when the option is absent; with no name, an empty filter.
650
+ """
651
+ if values is None:
652
+ return None
653
+ if values == [ALL_NAMES]:
654
+ return ALL
655
+ if ALL_NAMES in values:
656
+ raise StateError(f"{option} takes '*' alone or names, not both")
657
+ return NameFilter.of(values)
658
+
659
+
660
+ def _revoke(context: _Context) -> _Outcome:
661
+ """Reject a store check and its rules after showing it and asking."""
662
+ args = context.args
663
+ store = RuleStore(context.storage.root)
664
+ name = args.check_name
665
+ check = store.load().checks.get(name)
666
+ if check is None:
667
+ raise StateError(f"the rule-automation store has no check {name!r}")
668
+ if check.status == "rejected":
669
+ raise StateError(f"check {name!r} is already rejected")
670
+ reason = (args.reason or "").strip()
671
+ recorded = "revoked by the operator" + (f": {reason}" if reason else "")
672
+ sys.stderr.write(render_revoke_preview(name, check))
673
+ if not confirm_operator(
674
+ "ww rules revoke",
675
+ f"reject check {name} ({check.status}) and the rules it covers, which "
676
+ "a verifier judges from then on",
677
+ "Revoke it?",
678
+ "Revoke",
679
+ assume_yes=args.yes,
680
+ ):
681
+ return _Outcome("", error="revoke cancelled", exit_code=1)
682
+ outcome: list[tuple[str, ...]] = []
683
+
684
+ def change(automation: RuleAutomation) -> RuleAutomation:
685
+ updated, rules = revoke_check(automation, name, recorded)
686
+ outcome.append(rules)
687
+ return updated
688
+
689
+ store.modify(change)
690
+ rules = outcome[-1]
691
+ if args.json_output:
692
+ return _Outcome(
693
+ _json(
694
+ {
695
+ "revoked": {
696
+ "check": name,
697
+ "rules": list(rules),
698
+ "reason": recorded,
699
+ "config": list(check.spec.config),
700
+ }
701
+ }
702
+ )
703
+ )
704
+ return _Outcome(render_revoked(name, rules, check.spec.config))
705
+
706
+
707
+ def _convert(context: _Context, configuration: WorkflowConfiguration) -> _Outcome:
708
+ """Record an approved check covering the named rules, after asking."""
709
+ args = context.args
710
+ rules = rule_conversion.declared_rules(configuration, tuple(args.covers))
711
+ mapping = rule_writes.check_mapping(
712
+ args.check_shell,
713
+ tuple(args.check_argv) if args.check_argv else None,
714
+ tuple(args.assertion) if args.assertion else None,
715
+ )
716
+ assert mapping is not None # argparse requires one of the commands
717
+ spec = CheckSpec(
718
+ command=parse_command(mapping, "--check"),
719
+ config=tuple(args.config),
720
+ proven=args.proven,
721
+ )
722
+ store = RuleStore(context.storage.root)
723
+ name = args.check_name
724
+ now = _store_time()
725
+
726
+ def change(automation: RuleAutomation) -> rule_conversion.StoreChange:
727
+ return rule_conversion.convert(automation, name, spec, rules, now)
728
+
729
+ current = store.load()
730
+ preview = render_convert_preview(name, rules, current, change(current))
731
+ if args.dry_run:
732
+ return _Outcome(preview + "Dry run: nothing was recorded.\n")
733
+ sys.stderr.write(preview)
734
+ if not confirm_operator(
735
+ "ww rules convert",
736
+ f"record check {name} as converted, run for the {len(rules)} rule(s) "
737
+ "it covers wherever its config files exist",
738
+ "Record it?",
739
+ "Convert",
740
+ assume_yes=args.yes,
741
+ ):
742
+ return _Outcome("", error="convert cancelled", exit_code=1)
743
+ result = _modify_store(store, change)
744
+ if args.json_output:
745
+ return _Outcome(
746
+ _json(
747
+ {
748
+ "converted": {
749
+ "check": name,
750
+ "rules": [rule.id for rule in rules],
751
+ **_change_json(result),
752
+ }
753
+ }
754
+ )
755
+ )
756
+ text = f"Recorded check {name}, covering {', '.join(rule.id for rule in rules)}.\n"
757
+ if result.unscriptized:
758
+ text += (
759
+ f"No longer covered, so not scriptized: {len(result.unscriptized)} "
760
+ "rule wording(s) ("
761
+ + ", ".join(key[:12] for key in result.unscriptized)
762
+ + ").\n"
763
+ )
764
+ return _Outcome(text + _dropped_text(result))
765
+
766
+
767
+ def _decline(context: _Context, configuration: WorkflowConfiguration) -> _Outcome:
768
+ """Record rules as not convertible, after asking."""
769
+ args = context.args
770
+ rules = rule_conversion.declared_rules(configuration, tuple(args.rule_ids))
771
+ reason = args.reason.strip()
772
+ if not reason:
773
+ raise StateError("rules decline needs --reason")
774
+ store = RuleStore(context.storage.root)
775
+
776
+ def change(automation: RuleAutomation) -> rule_conversion.StoreChange:
777
+ return rule_conversion.decline(automation, rules, reason)
778
+
779
+ current = store.load()
780
+ preview = render_decline_preview(rules, reason, current, change(current))
781
+ if args.dry_run:
782
+ return _Outcome(preview + "Dry run: nothing was recorded.\n")
783
+ sys.stderr.write(preview)
784
+ if not confirm_operator(
785
+ "ww rules decline",
786
+ f"record {len(rules)} rule(s) as not convertible, judged by a verifier "
787
+ "from then on",
788
+ "Record it?",
789
+ "Decline",
790
+ assume_yes=args.yes,
791
+ ):
792
+ return _Outcome("", error="decline cancelled", exit_code=1)
793
+ result = _modify_store(store, change)
794
+ if args.json_output:
795
+ return _Outcome(
796
+ _json(
797
+ {
798
+ "declined": {
799
+ "rules": [rule.id for rule in rules],
800
+ **_change_json(result),
801
+ }
802
+ }
803
+ )
804
+ )
805
+ return _Outcome(
806
+ f"Recorded {len(rules)} rule(s) as not convertible: "
807
+ + ", ".join(rule.id for rule in rules)
808
+ + ".\n"
809
+ + _dropped_text(result)
810
+ )
811
+
812
+
813
+ def _store_time() -> str:
814
+ """Now, as the rule-automation store records its times."""
815
+ return (
816
+ datetime.now(timezone.utc)
817
+ .replace(microsecond=0)
818
+ .isoformat()
819
+ .replace("+00:00", "Z")
820
+ )
821
+
822
+
823
+ def _modify_store(
824
+ store: RuleStore,
825
+ change: Callable[[RuleAutomation], rule_conversion.StoreChange],
826
+ ) -> rule_conversion.StoreChange:
827
+ """Apply ``change`` under the store's lock and return what it did."""
828
+ outcome: list[rule_conversion.StoreChange] = []
829
+
830
+ def apply(automation: RuleAutomation) -> RuleAutomation:
831
+ outcome.append(change(automation))
832
+ return outcome[-1].automation
833
+
834
+ store.modify(apply)
835
+ return outcome[-1]
836
+
837
+
838
+ def _change_json(change: rule_conversion.StoreChange) -> dict[str, object]:
839
+ return {
840
+ "unscriptized": list(change.unscriptized),
841
+ "moved": [{"rule": key, "from": check} for key, check in change.moved],
842
+ "dropped_checks": list(change.dropped_checks),
843
+ "dropped_revisions": list(change.dropped_revisions),
844
+ }
845
+
846
+
847
+ def _dropped_text(change: rule_conversion.StoreChange) -> str:
848
+ text = ""
849
+ if change.dropped_checks:
850
+ text += (
851
+ "Removed, covering nothing more: check(s) "
852
+ + ", ".join(change.dropped_checks)
853
+ + ".\n"
854
+ )
855
+ if change.dropped_revisions:
856
+ text += (
857
+ "Dropped the pending revision of check(s) "
858
+ + ", ".join(change.dropped_revisions)
859
+ + ".\n"
860
+ )
861
+ return text
862
+
863
+
864
+ def _prune(context: _Context, configuration: WorkflowConfiguration) -> _Outcome:
865
+ """Delete orphan store entries after listing them and asking the operator."""
866
+ store = RuleStore(context.storage.root)
867
+ automation = store.load()
868
+ declared = declared_hashes(configuration)
869
+ listed = orphans(automation, declared)
870
+ if not listed:
871
+ return _Outcome(
872
+ _json({"pruned": {"rules": [], "checks": []}})
873
+ if context.args.json_output
874
+ else render_orphans(listed, automation)
875
+ )
876
+ sys.stderr.write(render_orphans(listed, automation))
877
+ effect = (
878
+ f"delete these {len(listed.rules)} rule and {len(listed.checks)} check "
879
+ "entries from the rule-automation store"
880
+ )
881
+ if not confirm_operator(
882
+ "ww rules prune",
883
+ effect,
884
+ "Delete them?",
885
+ "Prune",
886
+ assume_yes=context.args.yes,
887
+ ):
888
+ return _Outcome("", error="prune cancelled", exit_code=1)
889
+ store.modify(lambda current: prune(current, listed, declared))
890
+ if context.args.json_output:
891
+ return _Outcome(
892
+ _json(
893
+ {"pruned": {"rules": list(listed.rules), "checks": list(listed.checks)}}
894
+ )
895
+ )
896
+ return _Outcome(
897
+ f"Pruned {len(listed.rules)} rule and {len(listed.checks)} check entries "
898
+ "from the rule-automation store.\n"
899
+ )
900
+
901
+
902
+ def _complete(context: _Context) -> _Outcome:
903
+ args = context.args
904
+ instruction = context.service.complete(
905
+ context.task_id,
906
+ _variables(args.variable),
907
+ args.artifact,
908
+ _metadata_values(args.metadata),
909
+ selected_agent=args.selected_agent,
910
+ selected_model=args.selected_model,
911
+ selected_reasoning=args.selected_reasoning,
912
+ summary_for_next=args.summary,
913
+ caller_role=args.role,
914
+ rule_results=tuple(args.rule_result),
915
+ assignment=args.assignment,
916
+ )
917
+ if args.role == "manager" and not args.no_dispatch:
918
+ # The manager's own next step is dispatched here, as ``next`` would,
919
+ # so the separate call is unnecessary.
920
+ followed = _dispatch_own_step(context, instruction)
921
+ if followed is not None:
922
+ return _instruction_outcome(followed, args.json_output)
923
+ return _instruction_outcome(instruction, args.json_output, completing=True)
924
+
925
+
926
+ def _dispatch_own_step(
927
+ context: _Context, handed_back: Instruction
928
+ ) -> Instruction | None:
929
+ """The page ``next`` shows for the manager's own next step, or ``None``."""
930
+ if not manager_continues_itself(handed_back):
931
+ return None
932
+ dispatched = context.service.next(context.task_id, caller_role="manager")
933
+ return replace(
934
+ dispatched,
935
+ notices=(
936
+ *dispatched.notices,
937
+ f"Completion recorded; ww dispatched your next step, "
938
+ f"`{dispatched.item_name}`, as `next` would. Pass `--no-dispatch` to "
939
+ "`complete` to receive the pending page instead.",
940
+ ),
941
+ )
942
+
943
+
944
+ def _interact(context: _Context) -> _Outcome:
945
+ args = context.args
946
+ if args.await_operator:
947
+ others = (
948
+ args.operator,
949
+ args.agent,
950
+ args.transcript,
951
+ args.choice,
952
+ args.end_interaction,
953
+ )
954
+ if any(others) or args.pause:
955
+ raise StateError(
956
+ "interact --await works the operator page on its own; record "
957
+ "entries with a separate interact call"
958
+ )
959
+ result = run_operator_page(
960
+ context.service,
961
+ context.task_id,
962
+ timeout=operator_wait_seconds(),
963
+ open_browser=webbrowser.open,
964
+ caller_role=args.role,
965
+ )
966
+ instruction = context.service.instruction(
967
+ context.task_id, caller_role=args.role, assignment=args.assignment
968
+ )
969
+ outcome = _instruction_outcome(instruction, args.json_output)
970
+ if args.json_output:
971
+ text = _json({**instruction.to_dict(), "operator_page": result.to_dict()})
972
+ else:
973
+ text = outcome.text.rstrip("\n") + "\n\n" + result.render()
974
+ return replace(outcome, text=text)
975
+ return _instruction_outcome(
976
+ context.service.interact(
977
+ context.task_id,
978
+ operator=args.operator,
979
+ agent=args.agent,
980
+ transcript=_transcript(args.transcript),
981
+ choice=args.choice,
982
+ end=args.end_interaction,
983
+ pause=args.pause,
984
+ caller_role=args.role,
985
+ assignment=args.assignment,
986
+ ),
987
+ args.json_output,
988
+ )
989
+
990
+
991
+ def _transcript(source: str | None) -> str | None:
992
+ """The transcript text from a file, or from stdin for ``-``."""
993
+ if source is None:
994
+ return None
995
+ if source == "-":
996
+ return sys.stdin.read()
997
+ try:
998
+ return Path(source).read_text(encoding="utf-8")
999
+ except OSError as error:
1000
+ raise StateError(f"cannot read the transcript {source}: {error}") from error
1001
+
1002
+
1003
+ def _feedback(context: _Context) -> _Outcome:
1004
+ args = context.args
1005
+ store = context.service.feedback
1006
+ if args.feedback_action == "list":
1007
+ result = store.listing()
1008
+ result["enabled"] = load_project_config(
1009
+ context.storage.project_config_path
1010
+ ).feedback_learning
1011
+ elif args.feedback_action == "get":
1012
+ if not args.task_id:
1013
+ raise StateError("feedback get needs a point ID")
1014
+ result = store.get(args.task_id)
1015
+ elif args.feedback_action in {"sources", "show"}:
1016
+ if not args.task_id:
1017
+ raise StateError("feedback sources needs a task ID")
1018
+ result = context.service.feedback_sources(args.task_id, args.run_id)
1019
+ elif args.feedback_action == "prune":
1020
+ result = store.prune(dry_run=args.dry_run, keep=tuple(args.keep))
1021
+ else:
1022
+ if not args.task_id or not args.analysis:
1023
+ raise StateError("feedback record needs a task ID and --analysis PATH")
1024
+ try:
1025
+ analysis = json.loads(_transcript(args.analysis) or "")
1026
+ except json.JSONDecodeError as error:
1027
+ raise StateError(f"invalid feedback analysis: {error}") from error
1028
+ result = context.service.record_feedback(
1029
+ args.task_id,
1030
+ analysis,
1031
+ run_id=args.run_id,
1032
+ caller_role=args.role,
1033
+ assignment=args.assignment,
1034
+ )
1035
+ if args.json_output:
1036
+ return _Outcome(_json(result))
1037
+ return _Outcome("# Operator feedback candidates\n\n" + _json(result))
1038
+
1039
+
1040
+ def _interactions(context: _Context) -> _Outcome:
1041
+ text = context.service.interactions_text(context.task_id)
1042
+ return _Outcome(text if text.endswith("\n") or not text else text + "\n")
1043
+
1044
+
1045
+ def _fail(context: _Context) -> _Outcome:
1046
+ args = context.args
1047
+ return _instruction_outcome(
1048
+ context.service.fail(
1049
+ context.task_id,
1050
+ args.error,
1051
+ caller_role=args.role,
1052
+ assignment=args.assignment,
1053
+ ),
1054
+ args.json_output,
1055
+ )
1056
+
1057
+
1058
+ def _status(context: _Context) -> _Outcome:
1059
+ args = context.args
1060
+ status = context.service.task_status(
1061
+ context.task_id, args.run_id, caller_role=args.role
1062
+ )
1063
+ return _with_interruption(
1064
+ context,
1065
+ _Outcome(
1066
+ render_status(status, args.json_output) + "\n",
1067
+ status.workflow,
1068
+ status.task_id,
1069
+ ),
1070
+ )
1071
+
1072
+
1073
+ def _requirements(context: _Context) -> _Outcome:
1074
+ args = context.args
1075
+ recorded = context.service.requirements(context.task_id, args.run_id)
1076
+ if args.json_output:
1077
+ return _Outcome(_json(recorded.to_dict()))
1078
+ lines = [f"# {recorded.task_id} · requirements", ""]
1079
+ lines.append(recorded.text or "No requirements were recorded.")
1080
+ if recorded.amendments:
1081
+ lines.extend(["", "## Amendments, oldest first", ""])
1082
+ lines.extend(
1083
+ f"- {entry.at} · {entry.role}: {entry.text}"
1084
+ for entry in recorded.amendments
1085
+ )
1086
+ return _Outcome("\n".join(lines) + "\n")
1087
+
1088
+
1089
+ def _amend(context: _Context) -> _Outcome:
1090
+ args = context.args
1091
+ amendment = context.service.amend(
1092
+ context.task_id,
1093
+ args.amendment,
1094
+ caller_role=args.role,
1095
+ assignment=args.assignment,
1096
+ )
1097
+ if args.json_output:
1098
+ return _Outcome(_json(amendment.to_dict()))
1099
+ return _Outcome(
1100
+ f"Recorded an amendment to the requirements of {context.task_id} "
1101
+ f"({amendment.role}, {amendment.at}). Later pages list it under the "
1102
+ "task requirements.\n"
1103
+ )
1104
+
1105
+
1106
+ def _instruction(context: _Context) -> _Outcome:
1107
+ args = context.args
1108
+ return _with_interruption(
1109
+ context,
1110
+ _instruction_outcome(
1111
+ context.service.instruction(
1112
+ context.task_id,
1113
+ args.run_id,
1114
+ caller_role=args.role,
1115
+ assignment=args.assignment,
1116
+ ),
1117
+ args.json_output,
1118
+ ),
1119
+ )
1120
+
1121
+
1122
+ def _with_interruption(context: _Context, outcome: _Outcome) -> _Outcome:
1123
+ """Lead with a notice while the task's last session stopped mid-step.
1124
+
1125
+ The notice shows until the interrupted attempt completes or fails, so a
1126
+ compaction or a new session between reading it and acting keeps it.
1127
+ """
1128
+ if context.args.json_output:
1129
+ return outcome
1130
+ interruption = context.service.interruption(context.task_id)
1131
+ if interruption is None:
1132
+ return outcome
1133
+ notice = interruption_notice(interruption, context.task_id)
1134
+ return replace(outcome, text=f"> {notice}\n\n{outcome.text}")
1135
+
1136
+
1137
+ def _hook(context: _Context) -> _Outcome:
1138
+ """Install, remove, or show ww's hooks for one agent."""
1139
+ args = context.args
1140
+ agent = hook_agent(args.agent)
1141
+ local = bool(args.local)
1142
+ target = hooks_file(agent, local)
1143
+ if args.hook_action == "show":
1144
+ return _Outcome(
1145
+ f"ww's hooks for {agent.name} belong in {target}:\n\n"
1146
+ + hook_snippet(agent)
1147
+ + _duplicate_notice(context, agent, local)
1148
+ )
1149
+ if args.hook_action == "install":
1150
+ installation = install_hooks(context.storage, agent, local=local)
1151
+ verb = {
1152
+ "installed": "Installed ww's hooks for",
1153
+ "unchanged": "ww's hooks are already installed for",
1154
+ }[installation.action]
1155
+ notice = _duplicate_notice(context, agent, local)
1156
+ else:
1157
+ installation = uninstall_hooks(context.storage, agent, local=local)
1158
+ verb = {
1159
+ "removed": "Removed ww's hooks for",
1160
+ "absent": "No ww hooks were installed for",
1161
+ }[installation.action]
1162
+ notice = ""
1163
+ return _Outcome(f"{verb} {agent.name} in {installation.path}.\n{notice}")
1164
+
1165
+
1166
+ def _duplicate_notice(context: _Context, agent: HookAgent, local: bool) -> str:
1167
+ """Warn that ww's hooks also sit in the agent's other project file."""
1168
+ other = registered_elsewhere(context.storage, agent, local=local)
1169
+ if other is None:
1170
+ return ""
1171
+ flag = "" if local else " --local"
1172
+ return (
1173
+ f"Notice: {other} also registers ww's hooks, so every hook runs twice. "
1174
+ f"Remove one copy with `{ww_command()} hook uninstall --agent "
1175
+ f"{agent.name}{flag}`.\n"
1176
+ )
1177
+
1178
+
1179
+ def _interrupted(context: _Context) -> _Outcome:
1180
+ """List the tasks whose last agent session stopped mid-step."""
1181
+ args = context.args
1182
+ since = (
1183
+ context.extensions.config.agent_hooks.recent_days
1184
+ if args.since is None
1185
+ else args.since
1186
+ )
1187
+ entries = (
1188
+ context.service.interruptions()
1189
+ if args.all
1190
+ else context.service.hook_records.recent(since)
1191
+ )
1192
+ if args.json_output:
1193
+ return _Outcome(
1194
+ _json(
1195
+ [
1196
+ {"task_id": task_id, **record.to_dict()}
1197
+ for task_id, record in entries
1198
+ ]
1199
+ )
1200
+ )
1201
+ if not entries:
1202
+ window = "" if args.all else f" in the last {since} day(s)"
1203
+ return _Outcome(f"No task was interrupted{window}.\n")
1204
+ lines = [
1205
+ f"- {task_id} · {record.step or record.item_name} (attempt "
1206
+ f"{record.attempt}) · {record.at} · {record.agent}"
1207
+ + (f" · {record.reason}" if record.reason else "")
1208
+ for task_id, record in entries
1209
+ ]
1210
+ return _Outcome("\n".join(lines) + "\n")
1211
+
1212
+
1213
+ def _metadata(context: _Context) -> _Outcome:
1214
+ args = context.args
1215
+ if args.project:
1216
+ if args.task_id is not None:
1217
+ raise StateError("metadata --project does not accept a task ID")
1218
+ return _Outcome(_json(context.service.project_metadata()))
1219
+ if args.task_id is None:
1220
+ raise StateError("metadata requires a task ID or --project")
1221
+ return _Outcome(_json(context.service.metadata(args.task_id)), None, args.task_id)
1222
+
1223
+
1224
+ def _onboarding(context: _Context) -> _Outcome:
1225
+ """Show the onboarding keys, after recording any ``--set`` ones."""
1226
+ onboarding = Onboarding(context.storage.root, context.storage.project_metadata)
1227
+ args = context.args
1228
+ state = onboarding.set(args.assignments) if args.assignments else onboarding.read()
1229
+ if args.json_output:
1230
+ return _Outcome(_json(state.to_dict()))
1231
+ return _Outcome(render_onboarding(state, context.storage.root))
1232
+
1233
+
1234
+ def _setup(context: _Context) -> _Outcome:
1235
+ """Show a setup fragment's placement, ask, and write it validated."""
1236
+ args = context.args
1237
+ root = context.storage.root
1238
+ config_path = context.storage.config_path
1239
+ if args.setup_action == "update":
1240
+ return _setup_update(context)
1241
+ plan = setup_apply.plan_setup(root, config_path, args.fragment, args.audience)
1242
+ # Only a change that would load is shown to the operator.
1243
+ setup_apply.validate_setup(root, config_path, plan)
1244
+ summary = setup_apply.render_plan(plan, root)
1245
+ if args.dry_run:
1246
+ if args.json_output:
1247
+ return _Outcome(_json(setup_apply.plan_to_dict(plan, root, applied=False)))
1248
+ return _Outcome(
1249
+ summary
1250
+ + "Dry run: the configuration would be valid; nothing was written.\n"
1251
+ + _inspected_plan(context, plan.writes)
1252
+ )
1253
+ sys.stderr.write(summary)
1254
+ if not confirm_operator(
1255
+ "ww setup apply",
1256
+ f"write these {len(plan.writes)} file(s) for "
1257
+ + ("the team" if plan.audience == "team" else "you only"),
1258
+ "Apply it?",
1259
+ "Setup",
1260
+ assume_yes=args.yes,
1261
+ ):
1262
+ return _Outcome("", error="setup apply cancelled", exit_code=1)
1263
+ setup_apply.apply_setup(plan)
1264
+ if args.json_output:
1265
+ return _Outcome(_json(setup_apply.plan_to_dict(plan, root, applied=True)))
1266
+ return _Outcome("Applied.\n" + summary)
1267
+
1268
+
1269
+ def _inspected_plan(context: _Context, writes: tuple[FileWrite, ...]) -> str:
1270
+ """The compiled plan of ``--inspect``'s workflow, as the writes would leave it."""
1271
+ args = context.args
1272
+ if args.inspect is None:
1273
+ return ""
1274
+ if args.agent is None:
1275
+ raise StateError("--inspect needs --agent")
1276
+ with staged_files({write.path: write.content for write in writes}):
1277
+ configuration = load_configuration(
1278
+ context.storage.config_path, context.extensions
1279
+ )
1280
+ plan = compile_workflow_plan(
1281
+ configuration,
1282
+ context.storage.root,
1283
+ args.inspect,
1284
+ args.agent,
1285
+ None,
1286
+ context.extensions,
1287
+ PlanCompilationOptions(),
1288
+ context.extensions.config,
1289
+ )
1290
+ return f"\nCompiled plan of {args.inspect} after this change:\n" + render_plan(
1291
+ plan, False
1292
+ )
1293
+
1294
+
1295
+ def _setup_update(context: _Context) -> _Outcome:
1296
+ """Show a one-workflow replacement as a diff, ask, and write it validated."""
1297
+ args = context.args
1298
+ root = context.storage.root
1299
+ config_path = context.storage.config_path
1300
+ plan = workflow_update.plan_update(
1301
+ root, config_path, args.workflow, args.fragment, args.level
1302
+ )
1303
+ workflow_update.validate_update(root, config_path, plan)
1304
+ summary = workflow_update.render_plan(plan)
1305
+ if args.dry_run:
1306
+ if args.json_output:
1307
+ return _Outcome(_json(workflow_update.plan_to_dict(plan, applied=False)))
1308
+ return _Outcome(
1309
+ summary
1310
+ + "Dry run: the configuration would be valid; nothing was written.\n"
1311
+ + _inspected_plan(context, (plan.write,))
1312
+ )
1313
+ sys.stderr.write(summary)
1314
+ if not confirm_operator(
1315
+ "ww setup update",
1316
+ f"replace workflow {plan.name} in {plan.label}",
1317
+ "Apply it?",
1318
+ "Update",
1319
+ assume_yes=args.yes,
1320
+ ):
1321
+ return _Outcome("", error="setup update cancelled", exit_code=1)
1322
+ workflow_update.apply_update(plan)
1323
+ if args.json_output:
1324
+ return _Outcome(_json(workflow_update.plan_to_dict(plan, applied=True)))
1325
+ return _Outcome("Applied.\n" + summary)
1326
+
1327
+
1328
+ def _documents(context: _Context) -> _Outcome:
1329
+ task_id = context.args.task_id
1330
+ return _Outcome(_json(context.service.documents_listing(task_id)), None, task_id)
1331
+
1332
+
1333
+ def _items(context: _Context) -> _Outcome:
1334
+ items = context.service.items(context.task_id, context.args.run_id)
1335
+ return _Outcome(_json([item.to_dict() for item in items]))
1336
+
1337
+
1338
+ def _item(context: _Context) -> _Outcome:
1339
+ args = context.args
1340
+ if (args.item_id is None) == (args.by is None):
1341
+ raise StateError("item takes --id or --by NAME=VALUE")
1342
+ if args.by is not None:
1343
+ ((name, value),) = _named_values([args.by], "--by")
1344
+ item = context.service.find_item(context.task_id, name, value, args.run_id)
1345
+ else:
1346
+ item = context.service.item(context.task_id, args.item_id, args.run_id)
1347
+ return _Outcome(_json(item.to_dict()))
1348
+
1349
+
1350
+ def _artifacts(context: _Context) -> _Outcome:
1351
+ artifacts = context.service.artifacts(context.task_id, context.args.run_id)
1352
+ return _Outcome(_json(list(artifacts)))
1353
+
1354
+
1355
+ def _add_item(context: _Context) -> _Outcome:
1356
+ args = context.args
1357
+ item = context.service.add_item(
1358
+ context.task_id,
1359
+ WorkItem(
1360
+ args.id,
1361
+ args.text,
1362
+ reference_to_id=args.refers_to,
1363
+ fields=_named_values(args.field, "--field"),
1364
+ ),
1365
+ )
1366
+ return _Outcome(_json(item.to_dict()))
1367
+
1368
+
1369
+ def _update_item(context: _Context) -> _Outcome:
1370
+ args = context.args
1371
+ changes = {
1372
+ name: value
1373
+ for name, value in (
1374
+ ("item", args.text),
1375
+ ("processed_item", args.processed_item),
1376
+ ("proposed_solution", args.proposed_solution),
1377
+ ("actual_solution", args.actual_solution),
1378
+ ("resolved", args.resolved),
1379
+ ("reported", args.reported),
1380
+ )
1381
+ if value is not None
1382
+ }
1383
+ if args.field:
1384
+ changes["fields"] = dict(_named_values(args.field, "--field"))
1385
+ result = context.service.update_item(
1386
+ context.task_id, args.item_id, caller_role=args.role, **changes
1387
+ )
1388
+ return _Outcome(
1389
+ render_item_update(result, args.json_output) + "\n", None, context.task_id
1390
+ )
1391
+
1392
+
1393
+ def _remove_item(context: _Context) -> _Outcome:
1394
+ args = context.args
1395
+ item = context.service.remove_item(context.task_id, args.item_id)
1396
+ return _Outcome(_json(item.to_dict()), None, context.task_id)
1397
+
1398
+
1399
+ def _add_child(context: _Context) -> _Outcome:
1400
+ args = context.args
1401
+ child = context.service.add_child(
1402
+ context.task_id,
1403
+ args.id,
1404
+ args.text,
1405
+ project=args.project,
1406
+ fields=_named_values(args.field, "--field"),
1407
+ )
1408
+ return _Outcome(_json(child.to_dict()))
1409
+
1410
+
1411
+ def _update_child(context: _Context) -> _Outcome:
1412
+ args = context.args
1413
+ child = context.service.update_child(
1414
+ context.task_id,
1415
+ args.child_id,
1416
+ text=args.text,
1417
+ project=args.project,
1418
+ fields=_named_values(args.field, "--field"),
1419
+ )
1420
+ return _Outcome(_json(child.to_dict()))
1421
+
1422
+
1423
+ def _start_child(context: _Context) -> _Outcome:
1424
+ args = context.args
1425
+ return _instruction_outcome(
1426
+ context.service.start_child(
1427
+ args.parent_task_id,
1428
+ args.child_id,
1429
+ workflow_name=args.workflow_name,
1430
+ workflow_runtime=args.workflow_runtime,
1431
+ model=args.model,
1432
+ reasoning=args.reasoning,
1433
+ agent=args.agent,
1434
+ ),
1435
+ args.json_output,
1436
+ )
1437
+
1438
+
1439
+ def _reset(context: _Context) -> _Outcome:
1440
+ args = context.args
1441
+ if not args.yes:
1442
+ raise WwError("reset requires --yes because it deletes task artifacts")
1443
+ result = context.service.reset(context.task_id)
1444
+ return _Outcome(
1445
+ render_reset(result, args.json_output) + "\n", None, context.task_id
1446
+ )
1447
+
1448
+
1449
+ def _inspect(context: _Context) -> _Outcome:
1450
+ profile = inspect_checkout(context.storage.root, context.args.commits)
1451
+ if context.args.json_output:
1452
+ return _Outcome(json.dumps(profile.to_dict(), indent=2) + "\n")
1453
+ return _Outcome(render_markdown(profile))
1454
+
1455
+
1456
+ def _cleanup(context: _Context) -> _Outcome:
1457
+ result = context.service.cleanup()
1458
+ if context.args.json_output:
1459
+ return _Outcome(json.dumps({"removed_locks": result.removed_locks}) + "\n")
1460
+ return _Outcome(f"Removed {result.removed_locks} inactive lock file(s).\n")
1461
+
1462
+
1463
+ def _extension(context: _Context) -> _Outcome:
1464
+ """Run one extension command against that extension's own store."""
1465
+ args = context.args
1466
+ command = context.extensions.command(args.extension_id, args.extension_command)
1467
+ try:
1468
+ result = command.run(
1469
+ ExtensionContext(
1470
+ root=context.storage.root,
1471
+ store=context.extensions.store(args.extension_id),
1472
+ config=context.extensions.settings(args.extension_id, args.project),
1473
+ arguments=tuple(args.extension_arguments),
1474
+ )
1475
+ )
1476
+ except Exception as error: # noqa: BLE001 - contain trusted extension failures
1477
+ raise StateError(
1478
+ f"extension command {args.extension_id}/{args.extension_command} failed: "
1479
+ f"{type(error).__name__}: {error}"
1480
+ ) from error
1481
+ if not isinstance(result, str):
1482
+ raise StateError("extension command returned a non-string result")
1483
+ return _Outcome(result + "\n")
1484
+
1485
+
1486
+ _HANDLERS: dict[str, Callable[[_Context], _Outcome]] = {
1487
+ "init": _init,
1488
+ "discover": lambda c: _Outcome(
1489
+ render_discover(c.storage, c.extensions, c.args.json_output) + "\n"
1490
+ ),
1491
+ "docs": lambda c: _Outcome(read_design_document(c.args.document)),
1492
+ "inspect": _inspect,
1493
+ "lookup": lambda c: _Outcome(
1494
+ render_lookup(
1495
+ c.storage,
1496
+ c.extensions,
1497
+ c.service.tasks,
1498
+ c.args.reference,
1499
+ c.args.agent,
1500
+ c.args.json_output,
1501
+ )
1502
+ + "\n"
1503
+ ),
1504
+ "modes": lambda c: _Outcome(_catalog_modes(c.storage, c.extensions) + "\n"),
1505
+ "workflows": lambda c: _Outcome(_catalog_workflows(c.storage, c.extensions) + "\n"),
1506
+ "runtimes": lambda c: _Outcome(_catalog_runtimes() + "\n"),
1507
+ "agents": lambda c: _Outcome(_catalog_agents() + "\n"),
1508
+ "projects": lambda c: _Outcome(_catalog_projects(c.extensions) + "\n"),
1509
+ "extensions": lambda c: _Outcome(_catalog_extensions(c.extensions) + "\n"),
1510
+ "extension": _extension,
1511
+ "plan": _plan,
1512
+ "lint": _lint,
1513
+ "start": _start,
1514
+ "next": _next,
1515
+ "loop": _loop,
1516
+ "complete": _complete,
1517
+ "check": _check,
1518
+ "dispute": _dispute,
1519
+ "rule": _rule,
1520
+ "rules": _rules,
1521
+ "interact": _interact,
1522
+ "interactions": _interactions,
1523
+ "feedback": _feedback,
1524
+ "fail": _fail,
1525
+ "status": _status,
1526
+ "instruction": _instruction,
1527
+ "requirements": _requirements,
1528
+ "amend": _amend,
1529
+ "metadata": _metadata,
1530
+ "documents": _documents,
1531
+ "onboarding": _onboarding,
1532
+ "setup": _setup,
1533
+ "items": _items,
1534
+ "item": _item,
1535
+ "artifacts": _artifacts,
1536
+ "add-item": _add_item,
1537
+ "update-item": _update_item,
1538
+ "remove-item": _remove_item,
1539
+ "add-child": _add_child,
1540
+ "update-child": _update_child,
1541
+ "start-child": _start_child,
1542
+ "reset": _reset,
1543
+ "cleanup": _cleanup,
1544
+ "updates": lambda c: _Outcome(render_updates(c.storage, c.args)),
1545
+ "hook": _hook,
1546
+ "interrupted": _interrupted,
1547
+ }
1548
+
1549
+
1550
+ def main(argv: list[str] | None = None) -> int:
1551
+ arguments = sys.argv[1:] if argv is None else argv
1552
+ if _runtime_hook_call(arguments):
1553
+ return _answer_hook(arguments)
1554
+ args = build_parser().parse_args(arguments)
1555
+ args.invocation_id = str(uuid.uuid4())
1556
+ if (
1557
+ getattr(args, "role", None) == "worker"
1558
+ and args.command in _MANAGER_ONLY_COMMANDS
1559
+ ):
1560
+ print(f"ww error: {args.command} is a manager-role command", file=sys.stderr)
1561
+ return 1
1562
+ if args.command == "next":
1563
+ if args.force and not args.force_reason:
1564
+ print("ww error: next --force requires --reason", file=sys.stderr)
1565
+ return 1
1566
+ if args.retry and not confirm_interrupted_retry(assume_yes=args.yes):
1567
+ return 1
1568
+ if args.retry:
1569
+ args.confirmation = _confirmation(args.yes)
1570
+ if args.yes and not (args.retry or args.force or args.replan):
1571
+ print(
1572
+ "ww error: --yes confirms next --retry, --force or --replan",
1573
+ file=sys.stderr,
1574
+ )
1575
+ return 1
1576
+ storage = Storage(
1577
+ (args.root or Path.cwd()).resolve()
1578
+ if args.command == "init"
1579
+ else _resolve_project_root(args.root)
1580
+ )
1581
+ if args.command == "upgrade":
1582
+ try:
1583
+ sys.stdout.write(upgrade(pre=args.pre))
1584
+ except WwError as error:
1585
+ print(f"ww error: {error}", file=sys.stderr)
1586
+ return 1
1587
+ return 0
1588
+ # An available update is reported before the command's own output, so an
1589
+ # agent relaying that output shows it to the operator first. ``updates``
1590
+ # is exempt: it is the command that reports one.
1591
+ if args.command != "updates":
1592
+ announce(storage, to_stderr=_machine_readable(args))
1593
+ workflow = cast(str | None, getattr(args, "workflow", None))
1594
+ task_id = cast(str | None, getattr(args, "task_id", None))
1595
+ logged = args.command not in _READ_ONLY_COMMANDS and not (
1596
+ (args.command == "rules" and args.rules_action is None)
1597
+ or (
1598
+ args.command == "feedback"
1599
+ and (args.feedback_action not in {"record", "prune"} or args.dry_run)
1600
+ )
1601
+ or (args.command == "onboarding" and not args.assignments)
1602
+ )
1603
+
1604
+ def log(outcome: str, error: str | None, *scope: str | None) -> None:
1605
+ record_workflow, record_task = scope if scope else (workflow, task_id)
1606
+ storage.append_log(
1607
+ _log_record(
1608
+ args.command, record_workflow, record_task, outcome, error, args
1609
+ )
1610
+ )
1611
+
1612
+ try:
1613
+ extensions = ExtensionRegistry.discover(storage.root)
1614
+ service = WorkflowService(storage, extensions=extensions)
1615
+ # Check what the force would do before asking the operator to approve
1616
+ # it, so a refused force is reported instead of confirmed and refused.
1617
+ if args.command == "next" and args.force:
1618
+ effect = service.force_target(args.task_id)
1619
+ if not _confirm_force_next(effect, assume_yes=args.yes):
1620
+ return 1
1621
+ args.confirmation = _confirmation(args.yes)
1622
+ # A replan that rewinds runs finished steps again: the operator agrees
1623
+ # to that, knowing which ones, before anything changes.
1624
+ if args.command == "next" and args.replan:
1625
+ change = service.plan_change(args.task_id)
1626
+ if change is not None and change.refusal is None and change.reruns:
1627
+ if not confirm_operator(
1628
+ "ww next --replan",
1629
+ "run these finished steps again under the new definition: "
1630
+ + ", ".join(change.reruns),
1631
+ "Replan and rerun them?",
1632
+ "Replan",
1633
+ assume_yes=args.yes,
1634
+ ):
1635
+ return 1
1636
+ args.confirmation = _confirmation(args.yes)
1637
+ if logged:
1638
+ log("started", None)
1639
+ # Every command this invocation prints starts with the project's ww.
1640
+ with printed_executable(extensions.config.executable):
1641
+ result = _HANDLERS[args.command](
1642
+ _Context(args, storage, extensions, service)
1643
+ )
1644
+ except WwError as error:
1645
+ if logged:
1646
+ with contextlib.suppress(OSError, WwError):
1647
+ log("error", str(error))
1648
+ print(f"ww error: {error}", file=sys.stderr)
1649
+ return 1
1650
+ if logged:
1651
+ log(
1652
+ "error" if result.error is not None else "ok",
1653
+ result.error,
1654
+ result.workflow,
1655
+ result.task_id,
1656
+ )
1657
+ sys.stdout.write(result.text)
1658
+ return result.exit_code
1659
+
1660
+
1661
+ def _runtime_hook_call(arguments: list[str]) -> bool:
1662
+ """Whether an agent is calling one of ww's hooks, not setting them up."""
1663
+ return any(
1664
+ value == "hook"
1665
+ and index + 1 < len(arguments)
1666
+ and arguments[index + 1] in HOOK_EVENTS
1667
+ for index, value in enumerate(arguments)
1668
+ )
1669
+
1670
+
1671
+ def _answer_hook(arguments: list[str]) -> int:
1672
+ """Answer an agent's hook call; this never breaks the agent.
1673
+
1674
+ Any failure, a bad argument included, exits 0 with no output: exit code
1675
+ 2 means "continue" to some agents' stop hooks, and an unexpected message
1676
+ would become part of the agent's context. Every call is still written to
1677
+ the audit log, with ww's decision, so a session can be followed there.
1678
+ """
1679
+ storage: Storage | None = None
1680
+ args: argparse.Namespace | None = None
1681
+ try:
1682
+ with contextlib.redirect_stderr(io.StringIO()):
1683
+ args = build_parser().parse_args(arguments)
1684
+ args.invocation_id = str(uuid.uuid4())
1685
+ root = _resolve_project_root(args.root)
1686
+ if not is_project_root(root):
1687
+ return 0
1688
+ storage = Storage(root)
1689
+ payload = "" if sys.stdin is None or sys.stdin.isatty() else sys.stdin.read()
1690
+ config = load_project_config(root / SETTINGS_FILE)
1691
+ with printed_executable(config.executable):
1692
+ answer = answer_hook(
1693
+ storage,
1694
+ hook_agent(args.agent),
1695
+ args.hook_action,
1696
+ payload,
1697
+ on_request=config.on_request,
1698
+ settings=config.agent_hooks,
1699
+ )
1700
+ except BaseException as error: # noqa: BLE001 - a hook must never break the agent
1701
+ if isinstance(error, KeyboardInterrupt):
1702
+ return 0
1703
+ _log_hook(storage, args, "error", type(error).__name__)
1704
+ return 0
1705
+ _log_hook(storage, args, "ok", answer.decision)
1706
+ sys.stdout.write(answer.text)
1707
+ return 0
1708
+
1709
+
1710
+ def _log_hook(
1711
+ storage: Storage | None,
1712
+ args: argparse.Namespace | None,
1713
+ outcome: str,
1714
+ decision: str,
1715
+ ) -> None:
1716
+ """Record one hook call and what ww decided, never the agent's payload."""
1717
+ if storage is None or args is None:
1718
+ return
1719
+ record: dict[str, object] = {
1720
+ **_log_record("hook", None, None, outcome, None, args),
1721
+ "hook_event": args.hook_action,
1722
+ "hook_agent": args.agent,
1723
+ "hook_decision": decision,
1724
+ }
1725
+ with contextlib.suppress(Exception):
1726
+ storage.append_log(record)
1727
+
1728
+
1729
+ def _machine_readable(args: argparse.Namespace) -> bool:
1730
+ """Whether this invocation's stdout is meant for a program."""
1731
+ return bool(getattr(args, "json_output", False)) or (
1732
+ args.command in _MACHINE_READABLE_COMMANDS
1733
+ )
1734
+
1735
+
1736
+ def _resolve_project_root(explicit_root: Path | None) -> Path:
1737
+ """Find the durable project root when invoked from a linked worktree.
1738
+
1739
+ A linked Git worktree has its own checkout but shares the primary
1740
+ repository's git directory. ww's task state, configuration, and runtime
1741
+ files belong to that primary project, so an omitted ``--root`` follows the
1742
+ shared git directory back to its checkout. An explicit ``--root`` is always
1743
+ honored for embedded and non-Git projects.
1744
+ """
1745
+ if explicit_root is not None:
1746
+ return explicit_root.resolve()
1747
+ current = Path.cwd().resolve()
1748
+ try:
1749
+ result = subprocess.run(
1750
+ ["git", "rev-parse", "--git-common-dir"],
1751
+ cwd=current,
1752
+ capture_output=True,
1753
+ text=True,
1754
+ check=False,
1755
+ )
1756
+ except OSError:
1757
+ result = None
1758
+ if result is not None and result.returncode == 0:
1759
+ common_dir = Path(result.stdout.strip())
1760
+ if not common_dir.is_absolute():
1761
+ common_dir = current / common_dir
1762
+ resolved = common_dir.resolve()
1763
+ if resolved.name == ".git":
1764
+ return resolved.parent
1765
+ for candidate in (current, *current.parents):
1766
+ if (candidate / WORKFLOWS_FILE).is_file() or (candidate / ".ww").exists():
1767
+ return candidate
1768
+ return current