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