ww-agentic-workflows 1.0.0.dev3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ww/__init__.py +18 -0
- ww/_bundled_extensions/ww/git/extension.py +1728 -0
- ww/action_execution.py +887 -0
- ww/actions/__init__.py +94 -0
- ww/actions/command.py +444 -0
- ww/actions/contracts.py +699 -0
- ww/actions/extension.py +197 -0
- ww/actions/mcp.py +84 -0
- ww/actions/prompt.py +74 -0
- ww/actions/skill.py +62 -0
- ww/actions/slash_command.py +63 -0
- ww/agents.py +151 -0
- ww/amendments.py +54 -0
- ww/artifacts.py +93 -0
- ww/assessments.py +181 -0
- ww/assets/__init__.py +2 -0
- ww/assets/agent_instructions.md +49 -0
- ww/assets/docs/examples.md +879 -0
- ww/assets/docs/features.md +4639 -0
- ww/assets/docs/specification.md +1876 -0
- ww/assets/noww_skill.md +11 -0
- ww/assets/workflows/catchall.yaml +26 -0
- ww/assets/workflows/onboarding.yaml +586 -0
- ww/assets/workflows/scriptize.yaml +130 -0
- ww/assets/ww-automate_skill.md +23 -0
- ww/assets/ww-deduce-feedback_skill.md +38 -0
- ww/assets/ww-feedback-rules_skill.md +48 -0
- ww/assets/ww-learn-project_skill.md +22 -0
- ww/assets/ww-refresh_skill.md +26 -0
- ww/assets/ww-rule_skill.md +83 -0
- ww/assets/ww-rules-from-artifacts_skill.md +22 -0
- ww/assets/ww-scriptize_skill.md +33 -0
- ww/assets/ww-setup_skill.md +94 -0
- ww/assets/ww-solve_skill.md +23 -0
- ww/assets/ww-suggest_skill.md +32 -0
- ww/assets/ww-wizard_skill.md +105 -0
- ww/assets/ww_skill.md +59 -0
- ww/assignments.py +283 -0
- ww/bootstrap.py +405 -0
- ww/builtin_workflows.py +215 -0
- ww/changes.py +225 -0
- ww/child_coordination.py +482 -0
- ww/children.py +106 -0
- ww/claude_permissions.py +115 -0
- ww/cli/__init__.py +7 -0
- ww/cli/__main__.py +6 -0
- ww/cli/audit.py +129 -0
- ww/cli/catalogs.py +131 -0
- ww/cli/discover.py +607 -0
- ww/cli/initialization.py +898 -0
- ww/cli/lookup.py +287 -0
- ww/cli/main.py +1768 -0
- ww/cli/parser.py +1200 -0
- ww/cli/prompts.py +217 -0
- ww/cli/updates.py +117 -0
- ww/completion_artifacts.py +156 -0
- ww/completion_inputs.py +39 -0
- ww/config/__init__.py +582 -0
- ww/config/actions.py +591 -0
- ww/config/composition.py +571 -0
- ww/config/rules.py +511 -0
- ww/config/steps.py +1220 -0
- ww/config/values.py +223 -0
- ww/config_files.py +191 -0
- ww/config_writes.py +264 -0
- ww/contracts.py +155 -0
- ww/control.py +41 -0
- ww/defaults.py +130 -0
- ww/design_docs.py +32 -0
- ww/discovery.py +104 -0
- ww/documents.py +217 -0
- ww/errors.py +18 -0
- ww/executable.py +43 -0
- ww/execution_models/__init__.py +64 -0
- ww/execution_models/construction.py +148 -0
- ww/execution_models/decoding.py +38 -0
- ww/execution_models/plan_codec.py +565 -0
- ww/execution_models/records.py +1206 -0
- ww/execution_models/runs.py +266 -0
- ww/extensions/__init__.py +40 -0
- ww/extensions/api.py +559 -0
- ww/extensions/registry.py +864 -0
- ww/extensions/store.py +78 -0
- ww/feedback.py +342 -0
- ww/handler_repairs.py +57 -0
- ww/hooks/__init__.py +40 -0
- ww/hooks/agents.py +380 -0
- ww/hooks/install.py +168 -0
- ww/hooks/notices.py +206 -0
- ww/hooks/records.py +209 -0
- ww/hooks/runtime.py +266 -0
- ww/hooks/transcripts.py +183 -0
- ww/inspect.py +896 -0
- ww/instructions/__init__.py +17 -0
- ww/instructions/builder.py +1682 -0
- ww/instructions/commands.py +335 -0
- ww/instructions/handoff.py +149 -0
- ww/instructions/models.py +686 -0
- ww/instructions/policy.py +219 -0
- ww/instructions/text.py +168 -0
- ww/interactions.py +187 -0
- ww/interpolation.py +37 -0
- ww/item_passes.py +167 -0
- ww/items.py +99 -0
- ww/locking.py +207 -0
- ww/metadata_publication.py +230 -0
- ww/onboarding.py +229 -0
- ww/open_work.py +236 -0
- ww/operations.py +193 -0
- ww/operator_ui/__init__.py +16 -0
- ww/operator_ui/page.html +351 -0
- ww/operator_ui/server.py +215 -0
- ww/operator_ui/session.py +389 -0
- ww/operator_ui/sheet.py +104 -0
- ww/operator_ui/view.py +109 -0
- ww/output.py +339 -0
- ww/output_adapters/__init__.py +12 -0
- ww/output_adapters/base.py +25 -0
- ww/output_adapters/json_adapter.py +37 -0
- ww/output_adapters/markdown.py +2293 -0
- ww/output_adapters/rule_pages.py +337 -0
- ww/output_adapters/terminal.py +21 -0
- ww/package_updates.py +167 -0
- ww/plan/__init__.py +38 -0
- ww/plan/actions.py +207 -0
- ww/plan/compiler.py +1492 -0
- ww/plan/constructs.py +456 -0
- ww/plan/models.py +665 -0
- ww/project_config.py +752 -0
- ww/recovery.py +401 -0
- ww/replanning.py +367 -0
- ww/results.py +77 -0
- ww/rule_checks.py +230 -0
- ww/rule_conversion.py +331 -0
- ww/rule_disputes.py +148 -0
- ww/rule_store.py +456 -0
- ww/rule_verification.py +714 -0
- ww/rule_views.py +447 -0
- ww/rule_writes.py +920 -0
- ww/run_coordination.py +158 -0
- ww/runtimes.py +105 -0
- ww/service.py +4405 -0
- ww/setup_apply.py +428 -0
- ww/step_values.py +20 -0
- ww/storage.py +447 -0
- ww/storage_adapters/__init__.py +36 -0
- ww/storage_adapters/base.py +540 -0
- ww/storage_adapters/filesystem.py +370 -0
- ww/storage_adapters/memory.py +195 -0
- ww/storage_adapters/project_metadata.py +69 -0
- ww/storage_adapters/task_document.py +484 -0
- ww/task_ids.py +114 -0
- ww/task_references.py +124 -0
- ww/transitions.py +1619 -0
- ww/updates.py +399 -0
- ww/upgrade.py +95 -0
- ww/validation.py +168 -0
- ww/variables.py +275 -0
- ww/workflow_config.py +854 -0
- ww/workflow_update.py +239 -0
- ww/workflow_validation.py +1260 -0
- ww/workspace.py +50 -0
- ww_agentic_workflows-1.0.0.dev3.dist-info/METADATA +690 -0
- ww_agentic_workflows-1.0.0.dev3.dist-info/RECORD +167 -0
- ww_agentic_workflows-1.0.0.dev3.dist-info/WHEEL +4 -0
- ww_agentic_workflows-1.0.0.dev3.dist-info/entry_points.txt +2 -0
- ww_agentic_workflows-1.0.0.dev3.dist-info/licenses/LICENSE +674 -0
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
"""Markdown for the rule commands: ``check``, ``rule`` and ``rules``.
|
|
3
|
+
|
|
4
|
+
``ww check`` reads like the fix page a completion would produce, so a worker
|
|
5
|
+
sees the same failures in the same shape before it completes.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from ww.instructions.commands import dispute_command
|
|
11
|
+
from ww.instructions.models import CheckPreview
|
|
12
|
+
from ww.output_adapters.markdown import (
|
|
13
|
+
Lines,
|
|
14
|
+
_append_section,
|
|
15
|
+
_document,
|
|
16
|
+
_fix_failures,
|
|
17
|
+
_waivers,
|
|
18
|
+
)
|
|
19
|
+
from ww.rule_conversion import StoreChange, scriptize_state
|
|
20
|
+
from ww.rule_store import (
|
|
21
|
+
CheckEntry,
|
|
22
|
+
RuleAutomation,
|
|
23
|
+
describe_command,
|
|
24
|
+
)
|
|
25
|
+
from ww.rule_views import ListedRule, Orphans, RulesListing, RuleView
|
|
26
|
+
from ww.workflow_config import RuleDefinition
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _ids(ids: tuple[str, ...]) -> str:
|
|
30
|
+
return ", ".join(f"`{item}`" for item in ids)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def render_check_preview(preview: CheckPreview) -> str:
|
|
34
|
+
lines: Lines = [f"# {preview.task_id} · check `{preview.step}`", ""]
|
|
35
|
+
if not preview.checks and not preview.judged:
|
|
36
|
+
lines.append(
|
|
37
|
+
"This step has no checks to run and no rules for a verifier to judge."
|
|
38
|
+
)
|
|
39
|
+
return _document(lines)
|
|
40
|
+
if preview.failures:
|
|
41
|
+
lines.extend(
|
|
42
|
+
[
|
|
43
|
+
f"## {len(preview.failures)} of {preview.checks} checks failed",
|
|
44
|
+
"",
|
|
45
|
+
"Completing now would be rejected for these checks, on the files "
|
|
46
|
+
"this step changed so far:",
|
|
47
|
+
]
|
|
48
|
+
)
|
|
49
|
+
_fix_failures(lines, preview.failures)
|
|
50
|
+
lines.extend(
|
|
51
|
+
[
|
|
52
|
+
"",
|
|
53
|
+
"Fix the causes, or, when a check that already rejected a "
|
|
54
|
+
"completion is wrong for this change, dispute it:",
|
|
55
|
+
"",
|
|
56
|
+
"```console",
|
|
57
|
+
dispute_command(preview.task_id, assignment="<your-assignment-token>"),
|
|
58
|
+
"```",
|
|
59
|
+
]
|
|
60
|
+
)
|
|
61
|
+
elif preview.checks:
|
|
62
|
+
lines.append(f"All checks pass ({preview.checks} run).")
|
|
63
|
+
others = [
|
|
64
|
+
(label, ids)
|
|
65
|
+
for label, ids in (
|
|
66
|
+
("Passed", preview.passed),
|
|
67
|
+
(
|
|
68
|
+
"Not applicable (their globs match no changed file)",
|
|
69
|
+
preview.not_applicable,
|
|
70
|
+
),
|
|
71
|
+
)
|
|
72
|
+
if ids
|
|
73
|
+
]
|
|
74
|
+
if others:
|
|
75
|
+
lines.append("")
|
|
76
|
+
lines.extend(f"{label}: {_ids(ids)}." for label, ids in others)
|
|
77
|
+
if preview.judged:
|
|
78
|
+
lines.extend(
|
|
79
|
+
[
|
|
80
|
+
"",
|
|
81
|
+
f"Judged at completion: {_ids(preview.judged)}. A verifier, not "
|
|
82
|
+
"you, judges these rules after you complete.",
|
|
83
|
+
]
|
|
84
|
+
)
|
|
85
|
+
for reason, waived in _waivers(preview.waived):
|
|
86
|
+
lines.extend(["", f"Waived by the operator ({_ids(tuple(waived))}): {reason}."])
|
|
87
|
+
if preview.all_files:
|
|
88
|
+
lines.extend(
|
|
89
|
+
[
|
|
90
|
+
"",
|
|
91
|
+
"There is no change set (no git), so the checks saw every file "
|
|
92
|
+
"of the project their globs select.",
|
|
93
|
+
]
|
|
94
|
+
)
|
|
95
|
+
lines.extend(
|
|
96
|
+
[
|
|
97
|
+
"",
|
|
98
|
+
"Nothing was recorded: this does not count as an attempt, and the "
|
|
99
|
+
"checks' output was not kept.",
|
|
100
|
+
]
|
|
101
|
+
)
|
|
102
|
+
return _document(lines)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def render_rule_view(view: RuleView) -> str:
|
|
106
|
+
kind = {
|
|
107
|
+
"rule": "Rule",
|
|
108
|
+
"hook": "Hook check",
|
|
109
|
+
"derived": "Derived check",
|
|
110
|
+
}.get(view.kind, "Check")
|
|
111
|
+
lines: Lines = [f"# {kind} `{view.id}`", "", view.text]
|
|
112
|
+
facts: Lines = []
|
|
113
|
+
if view.paths:
|
|
114
|
+
facts.append(f"- Applies to: {', '.join(view.paths)}")
|
|
115
|
+
if view.source:
|
|
116
|
+
facts.append(f"- Rule file: {view.source}")
|
|
117
|
+
elif view.kind == "rule":
|
|
118
|
+
facts.append("- Written in the step's own `rules` list.")
|
|
119
|
+
elif view.kind == "hook":
|
|
120
|
+
facts.append("- A `before_complete` hook with `on_failure: fix`.")
|
|
121
|
+
elif view.kind == "derived":
|
|
122
|
+
facts.append(
|
|
123
|
+
"- An approved check from the rule-automation store, covering "
|
|
124
|
+
+ _ids(view.covers)
|
|
125
|
+
+ "."
|
|
126
|
+
)
|
|
127
|
+
if view.steps:
|
|
128
|
+
facts.append(f"- Steps of this task: {_ids(view.steps)}")
|
|
129
|
+
if view.command:
|
|
130
|
+
facts.append(f"- Command: `{view.command}`")
|
|
131
|
+
if view.assertion:
|
|
132
|
+
facts.append(f"- {view.assertion}")
|
|
133
|
+
if view.max_fixes is not None:
|
|
134
|
+
facts.append(f"- Fix rounds allowed: {view.max_fixes}")
|
|
135
|
+
elif view.kind == "rule":
|
|
136
|
+
facts.append("- No command: a verifier judges it when the step completes.")
|
|
137
|
+
if view.store_status:
|
|
138
|
+
store = f"- Rule-automation store: {view.store_status}"
|
|
139
|
+
if view.store_check:
|
|
140
|
+
store += f" (check `{view.store_check}`)"
|
|
141
|
+
facts.append(store)
|
|
142
|
+
elif view.kind == "rule" and not view.command:
|
|
143
|
+
facts.append("- Rule-automation store: nothing yet")
|
|
144
|
+
if view.interpretation:
|
|
145
|
+
facts.append(f"- Interpretation: {view.interpretation}")
|
|
146
|
+
if view.resolution:
|
|
147
|
+
facts.append(f"- Enforced in the current step as: {view.resolution}")
|
|
148
|
+
if view.text_hash:
|
|
149
|
+
facts.append(f"- Wording hash: {view.text_hash[:12]}")
|
|
150
|
+
lines.extend(["", *facts])
|
|
151
|
+
return _document(lines)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def render_rules_listing(listing: RulesListing) -> str:
|
|
155
|
+
lines: Lines = ["# Rules"]
|
|
156
|
+
if not listing.groups and not listing.steps:
|
|
157
|
+
lines.extend(["", "No rules are declared."])
|
|
158
|
+
return _document(lines)
|
|
159
|
+
for group in listing.groups:
|
|
160
|
+
_append_section(lines, f"Group `{group.name}`")
|
|
161
|
+
filters = [
|
|
162
|
+
f"{label} {_ids(names.listed)}"
|
|
163
|
+
for label, names in (("workflows", group.workflows), ("steps", group.steps))
|
|
164
|
+
if not names.admits_all
|
|
165
|
+
]
|
|
166
|
+
if group.workflows.admits_none or group.steps.admits_none:
|
|
167
|
+
scope = "only where a step names it"
|
|
168
|
+
elif filters:
|
|
169
|
+
scope = "; ".join(filters)
|
|
170
|
+
else:
|
|
171
|
+
scope = "every step"
|
|
172
|
+
lines.append(f"Applies to: {scope}.")
|
|
173
|
+
if group.origin != "configuration":
|
|
174
|
+
lines.append(f"Shipped by: {group.origin}.")
|
|
175
|
+
if group.hints:
|
|
176
|
+
lines.append(
|
|
177
|
+
"Verifier hints: "
|
|
178
|
+
+ ", ".join(f"{key} {value}" for key, value in group.hints.items())
|
|
179
|
+
+ "."
|
|
180
|
+
)
|
|
181
|
+
_rule_lines(lines, group.rules)
|
|
182
|
+
for step in listing.steps:
|
|
183
|
+
_append_section(lines, f"Step `{step.step}`")
|
|
184
|
+
if step.groups:
|
|
185
|
+
lines.append(f"Names the groups {_ids(step.groups)}.")
|
|
186
|
+
_rule_lines(lines, step.rules)
|
|
187
|
+
return _document(lines)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
# How a rule without a command is enforced when no store check runs for it.
|
|
191
|
+
_JUDGED_STATES = {
|
|
192
|
+
"not_convertible": " (judged: declined for scriptizing)",
|
|
193
|
+
"rejected": " (judged: its check was rejected)",
|
|
194
|
+
"unscriptized": " (judged: not scriptized yet)",
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _rule_lines(lines: Lines, rules: tuple[ListedRule, ...]) -> None:
|
|
199
|
+
if not rules:
|
|
200
|
+
return
|
|
201
|
+
lines.append("")
|
|
202
|
+
for rule in rules:
|
|
203
|
+
scope = f" — {', '.join(rule.paths)}" if rule.paths else ""
|
|
204
|
+
check = (
|
|
205
|
+
" (checked by its command)"
|
|
206
|
+
if rule.has_check
|
|
207
|
+
else f" (checked by store check `{rule.store_check}`)"
|
|
208
|
+
if rule.store_check
|
|
209
|
+
else _JUDGED_STATES.get(rule.scriptize, "")
|
|
210
|
+
)
|
|
211
|
+
disputed = (
|
|
212
|
+
f" (disputed {rule.disputes} time{'s' if rule.disputes != 1 else ''})"
|
|
213
|
+
if rule.disputes
|
|
214
|
+
else ""
|
|
215
|
+
)
|
|
216
|
+
lines.append(f"- `{rule.id}`{scope} — {rule.summary}{check}{disputed}")
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def render_orphans(listed: Orphans, automation: RuleAutomation) -> str:
|
|
220
|
+
"""The store entries ``ww rules prune`` would delete."""
|
|
221
|
+
if not listed:
|
|
222
|
+
return "The rule-automation store has no orphan entries.\n"
|
|
223
|
+
lines = ["Orphan entries in the rule-automation store:"]
|
|
224
|
+
lines.extend(
|
|
225
|
+
f"- rule {key[:12]} ({automation.rules[key].status}): "
|
|
226
|
+
f"{automation.rules[key].text}"
|
|
227
|
+
for key in listed.rules
|
|
228
|
+
)
|
|
229
|
+
lines.extend(
|
|
230
|
+
f"- check {name} ({automation.checks[name].status})" for name in listed.checks
|
|
231
|
+
)
|
|
232
|
+
return "\n".join(lines) + "\n"
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def render_convert_preview(
|
|
236
|
+
name: str,
|
|
237
|
+
rules: tuple[RuleDefinition, ...],
|
|
238
|
+
current: RuleAutomation,
|
|
239
|
+
change: StoreChange,
|
|
240
|
+
) -> str:
|
|
241
|
+
"""What ``ww rules convert`` would record, command in full."""
|
|
242
|
+
spec = change.automation.checks[name].spec
|
|
243
|
+
action = "replaces" if name in current.checks else "creates"
|
|
244
|
+
lines = [
|
|
245
|
+
f"Check {name} ({action} the store's entry): {describe_command(spec.command)}",
|
|
246
|
+
]
|
|
247
|
+
if spec.command.assertion is not None:
|
|
248
|
+
lines.append(f"- output must be: {spec.command.assertion.describe()}")
|
|
249
|
+
lines.append(
|
|
250
|
+
"- config files: " + (", ".join(spec.config) if spec.config else "none")
|
|
251
|
+
)
|
|
252
|
+
lines.append(f"- proven: {'yes' if spec.proven else 'no'}")
|
|
253
|
+
lines.append(f"- covers {len(rules)} rule(s):")
|
|
254
|
+
_covered_rules(lines, rules, current)
|
|
255
|
+
_side_effects(lines, rules, current, change)
|
|
256
|
+
return "\n".join(lines) + "\n"
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def render_decline_preview(
|
|
260
|
+
rules: tuple[RuleDefinition, ...],
|
|
261
|
+
reason: str,
|
|
262
|
+
current: RuleAutomation,
|
|
263
|
+
change: StoreChange,
|
|
264
|
+
) -> str:
|
|
265
|
+
"""What ``ww rules decline`` would record."""
|
|
266
|
+
lines = [f"Rules to record as not convertible ({reason}):"]
|
|
267
|
+
_covered_rules(lines, rules, current)
|
|
268
|
+
_side_effects(lines, rules, current, change)
|
|
269
|
+
return "\n".join(lines) + "\n"
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _covered_rules(
|
|
273
|
+
lines: Lines, rules: tuple[RuleDefinition, ...], current: RuleAutomation
|
|
274
|
+
) -> None:
|
|
275
|
+
"""Each rule with where it stands in the store now."""
|
|
276
|
+
for rule in rules:
|
|
277
|
+
entry = current.rules.get(rule.text_hash)
|
|
278
|
+
state: str = scriptize_state(current, rule)
|
|
279
|
+
if state == "converted" and entry is not None and entry.check:
|
|
280
|
+
state = f"converted by check {entry.check}"
|
|
281
|
+
lines.append(f" - `{rule.id}`: {rule.summary} (now: {state})")
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _side_effects(
|
|
285
|
+
lines: Lines,
|
|
286
|
+
rules: tuple[RuleDefinition, ...],
|
|
287
|
+
current: RuleAutomation,
|
|
288
|
+
change: StoreChange,
|
|
289
|
+
) -> None:
|
|
290
|
+
"""The store's other changes: moved rules, unscriptized ones, drops."""
|
|
291
|
+
ids = {rule.text_hash: rule.id for rule in rules}
|
|
292
|
+
for key, check in change.moved:
|
|
293
|
+
lines.append(f"- takes `{ids[key]}` out of check {check}")
|
|
294
|
+
for key in change.unscriptized:
|
|
295
|
+
lines.append(
|
|
296
|
+
f"- returns {_rule_name(current, key)} to not scriptized: no longer covered"
|
|
297
|
+
)
|
|
298
|
+
for check in change.dropped_checks:
|
|
299
|
+
status = current.checks[check].status
|
|
300
|
+
lines.append(f"- removes check {check} ({status}): it covers nothing more")
|
|
301
|
+
for check in change.dropped_revisions:
|
|
302
|
+
lines.append(f"- drops the pending revision of check {check}")
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _rule_name(automation: RuleAutomation, key: str) -> str:
|
|
306
|
+
entry = automation.rules.get(key)
|
|
307
|
+
text = entry.text.splitlines()[0] if entry is not None and entry.text else ""
|
|
308
|
+
return f"rule {key[:12]}" + (f" ({text})" if text else "")
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def render_revoke_preview(name: str, check: CheckEntry) -> str:
|
|
312
|
+
"""The check ``ww rules revoke`` would reject, as the operator reads it."""
|
|
313
|
+
spec = check.pending or check.spec
|
|
314
|
+
lines = [
|
|
315
|
+
f"Check {name} ({check.status}): {describe_command(spec.command)}",
|
|
316
|
+
f"- covers {len(spec.covers)} rule(s)",
|
|
317
|
+
]
|
|
318
|
+
if check.approved_by is not None:
|
|
319
|
+
lines.append(f"- approved by {check.approved_by}")
|
|
320
|
+
if spec.config:
|
|
321
|
+
lines.append("- config files: " + ", ".join(spec.config))
|
|
322
|
+
return "\n".join(lines) + "\n"
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def render_revoked(name: str, rules: tuple[str, ...], config: tuple[str, ...]) -> str:
|
|
326
|
+
"""What ``ww rules revoke`` changed, and what it left for the operator."""
|
|
327
|
+
listed = " (" + ", ".join(key[:12] for key in rules) + ")" if rules else ""
|
|
328
|
+
files = f" and the check's config files ({', '.join(config)})" if config else ""
|
|
329
|
+
lines = [
|
|
330
|
+
f"Revoked check {name}: it is rejected in the rule-automation store "
|
|
331
|
+
"and no longer runs.",
|
|
332
|
+
f"Rules rejected with it, judged by a verifier from now on: "
|
|
333
|
+
f"{len(rules)}{listed}",
|
|
334
|
+
f"Nothing else changed: the YAML and rule files{files} stay as they "
|
|
335
|
+
"are, for the operator to keep or remove.",
|
|
336
|
+
]
|
|
337
|
+
return "\n".join(lines) + "\n"
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
"""Terminal presentation shared by init prompts and completion output."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def terminal_accent(text: str) -> str:
|
|
11
|
+
if (
|
|
12
|
+
sys.stdout.isatty()
|
|
13
|
+
and "NO_COLOR" not in os.environ
|
|
14
|
+
and os.environ.get("TERM") != "dumb"
|
|
15
|
+
):
|
|
16
|
+
return f"\033[1;36m{text}\033[0m"
|
|
17
|
+
return text
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def initialization_progress(percent: int) -> str:
|
|
21
|
+
return terminal_accent(f"[{str(percent).rjust(6, '.')}%]")
|
ww/package_updates.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
"""Compatible PyPI versions and cached notices for packaged installations."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import platform
|
|
9
|
+
import sys
|
|
10
|
+
from dataclasses import asdict, dataclass
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
from urllib.error import URLError
|
|
15
|
+
from urllib.request import Request, urlopen
|
|
16
|
+
|
|
17
|
+
from packaging.specifiers import InvalidSpecifier, SpecifierSet
|
|
18
|
+
from packaging.tags import sys_tags
|
|
19
|
+
from packaging.utils import InvalidWheelFilename, parse_wheel_filename
|
|
20
|
+
from packaging.version import InvalidVersion, Version
|
|
21
|
+
|
|
22
|
+
from ww import __version__
|
|
23
|
+
from ww.executable import ww_command
|
|
24
|
+
from ww.updates import interval_seconds, state_path
|
|
25
|
+
|
|
26
|
+
PACKAGE_NAME = "ww-agentic-workflows"
|
|
27
|
+
PYPI_URL = f"https://pypi.org/pypi/{PACKAGE_NAME}/json"
|
|
28
|
+
HTTP_TIMEOUT_SECONDS = 2.0
|
|
29
|
+
MAX_RESPONSE_BYTES = 2_000_000
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class PackageUpdateNotice:
|
|
34
|
+
installed_version: str
|
|
35
|
+
available_version: str
|
|
36
|
+
|
|
37
|
+
def to_dict(self) -> dict[str, str]:
|
|
38
|
+
return asdict(self)
|
|
39
|
+
|
|
40
|
+
def render(self) -> str:
|
|
41
|
+
return (
|
|
42
|
+
"## A newer ww is available\n\n"
|
|
43
|
+
f"{self.installed_version} → {self.available_version}\n"
|
|
44
|
+
f"Run: `{ww_command()} upgrade`\n\n"
|
|
45
|
+
"This notice is shown once. `ww updates` prints it again.\n\n---\n\n"
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def allows_prereleases(installed: str, *, pre: bool = False) -> bool:
|
|
50
|
+
return pre or Version(installed).is_prerelease
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def latest_compatible(
|
|
54
|
+
data: dict[str, Any], installed: str, *, pre: bool = False
|
|
55
|
+
) -> str | None:
|
|
56
|
+
"""Select only usable, non-yanked releases newer than this installation."""
|
|
57
|
+
current = Version(installed)
|
|
58
|
+
releases = data.get("releases")
|
|
59
|
+
if not isinstance(releases, dict):
|
|
60
|
+
return None
|
|
61
|
+
candidates: list[Version] = []
|
|
62
|
+
supported_tags = set(sys_tags())
|
|
63
|
+
for text, files in releases.items():
|
|
64
|
+
try:
|
|
65
|
+
candidate = Version(text)
|
|
66
|
+
except (InvalidVersion, TypeError):
|
|
67
|
+
continue
|
|
68
|
+
if candidate <= current or (
|
|
69
|
+
candidate.is_prerelease and not allows_prereleases(installed, pre=pre)
|
|
70
|
+
):
|
|
71
|
+
continue
|
|
72
|
+
if not isinstance(files, list):
|
|
73
|
+
continue
|
|
74
|
+
for file in files:
|
|
75
|
+
if not isinstance(file, dict) or file.get("yanked", False):
|
|
76
|
+
continue
|
|
77
|
+
try:
|
|
78
|
+
requirement = file.get("requires_python") or ""
|
|
79
|
+
if not SpecifierSet(requirement).contains(platform.python_version()):
|
|
80
|
+
continue
|
|
81
|
+
if file.get("packagetype") == "bdist_wheel":
|
|
82
|
+
_, _, _, wheel_tags = parse_wheel_filename(file["filename"])
|
|
83
|
+
if not wheel_tags.intersection(supported_tags):
|
|
84
|
+
continue
|
|
85
|
+
elif file.get("packagetype") != "sdist":
|
|
86
|
+
continue
|
|
87
|
+
except (InvalidSpecifier, InvalidWheelFilename, KeyError, TypeError):
|
|
88
|
+
continue
|
|
89
|
+
candidates.append(candidate)
|
|
90
|
+
break
|
|
91
|
+
return str(max(candidates)) if candidates else None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def fetch_releases() -> dict[str, Any] | None:
|
|
95
|
+
"""Bound the network request; offline or malformed responses stay quiet."""
|
|
96
|
+
try:
|
|
97
|
+
request = Request(PYPI_URL, headers={"User-Agent": f"ww/{__version__}"})
|
|
98
|
+
with urlopen(request, timeout=HTTP_TIMEOUT_SECONDS) as response:
|
|
99
|
+
body = response.read(MAX_RESPONSE_BYTES + 1)
|
|
100
|
+
if len(body) > MAX_RESPONSE_BYTES:
|
|
101
|
+
return None
|
|
102
|
+
data = json.loads(body)
|
|
103
|
+
except (OSError, URLError, ValueError):
|
|
104
|
+
return None
|
|
105
|
+
return data if isinstance(data, dict) else None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def package_state_path() -> Path:
|
|
109
|
+
# Keep separate installations and stable/prerelease preferences independent.
|
|
110
|
+
key = hashlib.sha256(f"{sys.prefix}:{__version__}".encode()).hexdigest()[:16]
|
|
111
|
+
return state_path().with_name(f"package-updates-{key}.json")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _load(path: Path) -> dict[str, Any]:
|
|
115
|
+
try:
|
|
116
|
+
data = json.loads(path.read_text())
|
|
117
|
+
except (OSError, ValueError):
|
|
118
|
+
return {}
|
|
119
|
+
return data if isinstance(data, dict) else {}
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _save(path: Path, data: dict[str, Any]) -> None:
|
|
123
|
+
try:
|
|
124
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
125
|
+
temporary = path.with_suffix(".tmp")
|
|
126
|
+
temporary.write_text(json.dumps(data))
|
|
127
|
+
temporary.replace(path)
|
|
128
|
+
except OSError:
|
|
129
|
+
return
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def last_package_notice() -> PackageUpdateNotice | None:
|
|
133
|
+
data = _load(package_state_path())
|
|
134
|
+
available = data.get("available_version")
|
|
135
|
+
try:
|
|
136
|
+
if isinstance(available, str) and Version(available) > Version(__version__):
|
|
137
|
+
return PackageUpdateNotice(__version__, available)
|
|
138
|
+
except InvalidVersion:
|
|
139
|
+
pass
|
|
140
|
+
return None
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def pending_package_notice(*, force: bool = False) -> PackageUpdateNotice | None:
|
|
144
|
+
path = package_state_path()
|
|
145
|
+
data = _load(path)
|
|
146
|
+
now = datetime.now(timezone.utc)
|
|
147
|
+
try:
|
|
148
|
+
checked = datetime.fromisoformat(data["last_checked"])
|
|
149
|
+
due = (now - checked).total_seconds() >= interval_seconds()
|
|
150
|
+
except (KeyError, ValueError, TypeError):
|
|
151
|
+
due = True
|
|
152
|
+
if force or due:
|
|
153
|
+
releases = fetch_releases()
|
|
154
|
+
available = latest_compatible(releases, __version__) if releases else None
|
|
155
|
+
data.update(last_checked=now.isoformat(), available_version=available)
|
|
156
|
+
_save(path, data)
|
|
157
|
+
notice = last_package_notice()
|
|
158
|
+
if notice and (force or data.get("announced_version") != notice.available_version):
|
|
159
|
+
return notice
|
|
160
|
+
return None
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def mark_package_announced(notice: PackageUpdateNotice) -> None:
|
|
164
|
+
path = package_state_path()
|
|
165
|
+
data = _load(path)
|
|
166
|
+
data["announced_version"] = notice.available_version
|
|
167
|
+
_save(path, data)
|
ww/plan/__init__.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
"""Public plan data and compilation API."""
|
|
3
|
+
|
|
4
|
+
from ww.operations import ChildWorkflowRun, LoopBoundary, PlanOperation, WorkflowHandoff
|
|
5
|
+
|
|
6
|
+
from .compiler import (
|
|
7
|
+
ExecutionHints,
|
|
8
|
+
PlanCompilationOptions,
|
|
9
|
+
WorkflowPlanCompiler,
|
|
10
|
+
compile_workflow_plan,
|
|
11
|
+
)
|
|
12
|
+
from .models import (
|
|
13
|
+
PlanItem,
|
|
14
|
+
PlannedCheck,
|
|
15
|
+
PlannedMode,
|
|
16
|
+
PlannedRule,
|
|
17
|
+
VerificationTarget,
|
|
18
|
+
WorkflowPlan,
|
|
19
|
+
number_step_paths,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"ExecutionHints",
|
|
24
|
+
"ChildWorkflowRun",
|
|
25
|
+
"LoopBoundary",
|
|
26
|
+
"PlanOperation",
|
|
27
|
+
"PlanCompilationOptions",
|
|
28
|
+
"PlanItem",
|
|
29
|
+
"PlannedCheck",
|
|
30
|
+
"PlannedMode",
|
|
31
|
+
"PlannedRule",
|
|
32
|
+
"VerificationTarget",
|
|
33
|
+
"WorkflowPlan",
|
|
34
|
+
"WorkflowHandoff",
|
|
35
|
+
"WorkflowPlanCompiler",
|
|
36
|
+
"compile_workflow_plan",
|
|
37
|
+
"number_step_paths",
|
|
38
|
+
]
|