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/initialization.py
ADDED
|
@@ -0,0 +1,898 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
"""Interactive and flag-driven project initialization."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import json
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
from dataclasses import replace
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from ww import claude_permissions
|
|
15
|
+
from ww.config.composition import compose_configuration
|
|
16
|
+
from ww.config_files import runtime_ignored, settings_levels
|
|
17
|
+
from ww.defaults import SKILLS, WW_SKILL_NAME, default_settings, skill_location
|
|
18
|
+
from ww.discovery import AGENT_DIRECTORIES
|
|
19
|
+
from ww.errors import ConfigurationError, StateError
|
|
20
|
+
from ww.executable import DEFAULT_EXECUTABLE, PROJECT_LAUNCHER_COMMAND
|
|
21
|
+
from ww.hooks import (
|
|
22
|
+
HOOK_AGENTS,
|
|
23
|
+
HookInstallError,
|
|
24
|
+
hooks_installed,
|
|
25
|
+
install_hooks,
|
|
26
|
+
)
|
|
27
|
+
from ww.output_adapters.terminal import initialization_progress
|
|
28
|
+
from ww.project_config import ON_REQUEST, Enabled, compose_settings
|
|
29
|
+
from ww.results import InitializationResult
|
|
30
|
+
from ww.storage import Storage
|
|
31
|
+
|
|
32
|
+
from .prompts import (
|
|
33
|
+
_ask_checklist,
|
|
34
|
+
_ask_choice,
|
|
35
|
+
_ask_names,
|
|
36
|
+
_ask_yes_no,
|
|
37
|
+
_interactive_terminal,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
# The bundled Git extension, whose settings init writes.
|
|
41
|
+
GIT_EXTENSION = "ww/git"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _init_choices(storage: Storage) -> dict[str, object]:
|
|
45
|
+
path = storage.runtime_path / "init-choices.json"
|
|
46
|
+
if not path.exists():
|
|
47
|
+
return {}
|
|
48
|
+
try:
|
|
49
|
+
value = json.loads(path.read_text(encoding="utf-8"))
|
|
50
|
+
except (OSError, json.JSONDecodeError) as error:
|
|
51
|
+
raise StateError(f"Cannot read saved init choices: {error}") from error
|
|
52
|
+
if not isinstance(value, dict):
|
|
53
|
+
raise StateError("Saved init choices must be a JSON object")
|
|
54
|
+
return value
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _save_init_choice(storage: Storage, key: str, value: object) -> None:
|
|
58
|
+
choices = _init_choices(storage)
|
|
59
|
+
choices[key] = value
|
|
60
|
+
storage.locks.atomic_write(
|
|
61
|
+
storage.runtime_path / "init-choices.json",
|
|
62
|
+
json.dumps(choices, indent=2) + "\n",
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def install_agent_hooks(
|
|
67
|
+
storage: Storage, args: argparse.Namespace, result: InitializationResult
|
|
68
|
+
) -> InitializationResult:
|
|
69
|
+
"""Offer ww's hooks to each hook-capable agent this project is set up for.
|
|
70
|
+
|
|
71
|
+
An agent counts as set up when its directory exists, which is also where
|
|
72
|
+
init just installed its skills. The answer is remembered per agent, and
|
|
73
|
+
``--hooks``/``--no-hooks`` decide for every agent without asking, and
|
|
74
|
+
``--force`` asks again for every agent whose hooks are not installed,
|
|
75
|
+
when there is a terminal to ask at; without one the answers stand. A
|
|
76
|
+
hook installation that fails never fails init: the summary says how to
|
|
77
|
+
add the hooks by hand instead.
|
|
78
|
+
"""
|
|
79
|
+
interactive = not args.no_input and not args.json_output and sys.stdin.isatty()
|
|
80
|
+
saved = _init_choices(storage).get("hooks", {})
|
|
81
|
+
choices = dict(saved) if isinstance(saved, dict) else {}
|
|
82
|
+
remembered = {} if args.force and interactive else choices
|
|
83
|
+
created, preserved, actions = (
|
|
84
|
+
list(result.created),
|
|
85
|
+
list(result.preserved),
|
|
86
|
+
list(result.actions),
|
|
87
|
+
)
|
|
88
|
+
explained = False
|
|
89
|
+
for name, agent in HOOK_AGENTS.items():
|
|
90
|
+
if not (storage.root / AGENT_DIRECTORIES[name]).is_dir():
|
|
91
|
+
continue
|
|
92
|
+
if hooks_installed(storage, agent) and args.hooks is not False:
|
|
93
|
+
choices[name] = True
|
|
94
|
+
preserved.append(f"{agent.settings_file} (ww hooks)")
|
|
95
|
+
continue
|
|
96
|
+
wanted = args.hooks if args.hooks is not None else remembered.get(name)
|
|
97
|
+
if wanted is None and interactive:
|
|
98
|
+
if not explained:
|
|
99
|
+
print(
|
|
100
|
+
"\nww's hooks tell an agent session which ww tasks are "
|
|
101
|
+
"unfinished when it starts\nor compacts, remind it once to "
|
|
102
|
+
"record a step it leaves open, and note an\ninterrupted "
|
|
103
|
+
"session. They only add a few lines of context; nothing is "
|
|
104
|
+
"blocked.\n"
|
|
105
|
+
)
|
|
106
|
+
explained = True
|
|
107
|
+
try:
|
|
108
|
+
wanted = _ask_yes_no(
|
|
109
|
+
_init_prompt(60, f"Install ww hooks for {name}? [Y/n]: "), True
|
|
110
|
+
)
|
|
111
|
+
except EOFError:
|
|
112
|
+
# Input ended before the question: leave it open for next time.
|
|
113
|
+
break
|
|
114
|
+
if not isinstance(wanted, bool):
|
|
115
|
+
continue
|
|
116
|
+
choices[name] = wanted
|
|
117
|
+
if not wanted:
|
|
118
|
+
continue
|
|
119
|
+
try:
|
|
120
|
+
installation = install_hooks(storage, agent)
|
|
121
|
+
except (HookInstallError, OSError) as error:
|
|
122
|
+
actions.append(f"Add ww's hooks for {name} by hand: {error}")
|
|
123
|
+
continue
|
|
124
|
+
if installation.changed:
|
|
125
|
+
created.append(f"{agent.settings_file} (added ww hooks)")
|
|
126
|
+
if choices:
|
|
127
|
+
_save_init_choice(storage, "hooks", choices)
|
|
128
|
+
return replace(
|
|
129
|
+
result,
|
|
130
|
+
created=tuple(created),
|
|
131
|
+
preserved=tuple(preserved),
|
|
132
|
+
actions=tuple(actions),
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def install_claude_permissions(
|
|
137
|
+
storage: Storage, args: argparse.Namespace, result: InitializationResult
|
|
138
|
+
) -> InitializationResult:
|
|
139
|
+
"""Offer Bash allow rules for ww's role commands in Claude Code's local file.
|
|
140
|
+
|
|
141
|
+
Opt-in: the question's default is no, a terminal is needed to be asked,
|
|
142
|
+
and ``--permissions``/``--no-permissions`` answer without one. The answer
|
|
143
|
+
is remembered, so a later init refreshes the rules it was given for. The
|
|
144
|
+
rules name the project wrapper by absolute path; the file is merged, never
|
|
145
|
+
overwritten, and kept out of Git. A settings file that cannot be merged
|
|
146
|
+
never fails init: the summary says how to add the rules by hand.
|
|
147
|
+
"""
|
|
148
|
+
if not (storage.root / AGENT_DIRECTORIES["claudecode"]).is_dir():
|
|
149
|
+
return result
|
|
150
|
+
interactive = not args.no_input and not args.json_output and sys.stdin.isatty()
|
|
151
|
+
choices = _init_choices(storage)
|
|
152
|
+
remembered = None if args.force and interactive else choices.get("permissions")
|
|
153
|
+
wanted = args.permissions if args.permissions is not None else remembered
|
|
154
|
+
if wanted is None and interactive:
|
|
155
|
+
try:
|
|
156
|
+
wanted = _ask_yes_no(
|
|
157
|
+
_init_prompt(
|
|
158
|
+
65,
|
|
159
|
+
"Allow ww's role commands for Claude Code in "
|
|
160
|
+
f"{claude_permissions.SETTINGS_FILE}? [y/N]: ",
|
|
161
|
+
),
|
|
162
|
+
False,
|
|
163
|
+
)
|
|
164
|
+
except EOFError:
|
|
165
|
+
return result
|
|
166
|
+
if not isinstance(wanted, bool):
|
|
167
|
+
return result
|
|
168
|
+
_save_init_choice(storage, "permissions", wanted)
|
|
169
|
+
if not wanted:
|
|
170
|
+
return result
|
|
171
|
+
created, preserved, actions = (
|
|
172
|
+
list(result.created),
|
|
173
|
+
list(result.preserved),
|
|
174
|
+
list(result.actions),
|
|
175
|
+
)
|
|
176
|
+
rules = claude_permissions.role_rules(storage.root.resolve() / "ww")
|
|
177
|
+
try:
|
|
178
|
+
if claude_permissions.install_rules(storage.root, rules):
|
|
179
|
+
created.append(f"{claude_permissions.SETTINGS_FILE} (added ww permissions)")
|
|
180
|
+
else:
|
|
181
|
+
preserved.append(f"{claude_permissions.SETTINGS_FILE} (ww permissions)")
|
|
182
|
+
if claude_permissions.ensure_ignored(storage.root):
|
|
183
|
+
created.append(f".gitignore (added {claude_permissions.SETTINGS_FILE})")
|
|
184
|
+
except (StateError, OSError) as error:
|
|
185
|
+
actions.append(
|
|
186
|
+
f"Add these to permissions.allow in {claude_permissions.SETTINGS_FILE} "
|
|
187
|
+
f"by hand ({error}): " + ", ".join(rules)
|
|
188
|
+
)
|
|
189
|
+
return replace(
|
|
190
|
+
result,
|
|
191
|
+
created=tuple(created),
|
|
192
|
+
preserved=tuple(preserved),
|
|
193
|
+
actions=tuple(actions),
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _instructions_linked(storage: Storage) -> bool:
|
|
198
|
+
names = {"AGENTS.md"}
|
|
199
|
+
for directory, name in ((".claude", "CLAUDE.md"), (".gemini", "GEMINI.md")):
|
|
200
|
+
if (storage.root / directory).is_dir() or (storage.root / name).exists():
|
|
201
|
+
names.add(name)
|
|
202
|
+
return all(
|
|
203
|
+
(storage.root / name).is_file()
|
|
204
|
+
and "@WW_AGENT_INSTRUCTIONS.md"
|
|
205
|
+
in (storage.root / name).read_text(encoding="utf-8")
|
|
206
|
+
for name in names
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _init_prompt(percent: int, question: str) -> str:
|
|
211
|
+
"""Keep progress attached to the question currently being answered."""
|
|
212
|
+
return f"{initialization_progress(percent)} {question}"
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _link_agent_instructions(
|
|
216
|
+
storage: Storage, result: InitializationResult
|
|
217
|
+
) -> InitializationResult:
|
|
218
|
+
"""Append the approved reference while preserving existing instructions."""
|
|
219
|
+
names = {"AGENTS.md"}
|
|
220
|
+
for directory, filename in ((".claude", "CLAUDE.md"), (".gemini", "GEMINI.md")):
|
|
221
|
+
if (storage.root / directory).is_dir():
|
|
222
|
+
names.add(filename)
|
|
223
|
+
names.update(
|
|
224
|
+
name for name in ("CLAUDE.md", "GEMINI.md") if (storage.root / name).is_file()
|
|
225
|
+
)
|
|
226
|
+
created = list(result.created)
|
|
227
|
+
for name in sorted(names):
|
|
228
|
+
path = storage.root / name
|
|
229
|
+
content = path.read_text(encoding="utf-8") if path.exists() else ""
|
|
230
|
+
if "@WW_AGENT_INSTRUCTIONS.md" in content:
|
|
231
|
+
continue
|
|
232
|
+
separator = (
|
|
233
|
+
"\n\n"
|
|
234
|
+
if content and not content.endswith("\n")
|
|
235
|
+
else "\n"
|
|
236
|
+
if content
|
|
237
|
+
else ""
|
|
238
|
+
)
|
|
239
|
+
storage.locks.atomic_write(
|
|
240
|
+
path, content + separator + "@WW_AGENT_INSTRUCTIONS.md\n"
|
|
241
|
+
)
|
|
242
|
+
created.append(name + (" (added instruction reference)" if content else ""))
|
|
243
|
+
actions = tuple(
|
|
244
|
+
action for action in result.actions if "@WW_AGENT_INSTRUCTIONS.md" not in action
|
|
245
|
+
)
|
|
246
|
+
return replace(result, created=tuple(created), actions=actions)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _initialization_options(
|
|
250
|
+
storage: Storage, args: argparse.Namespace
|
|
251
|
+
) -> tuple[str, str, bool, tuple[tuple[str, str], ...]]:
|
|
252
|
+
interactive = not args.no_input and not args.json_output and sys.stdin.isatty()
|
|
253
|
+
# ``--force`` reopens the remembered questions only where it can ask them
|
|
254
|
+
# again; without a terminal the remembered answers stand, and init adds
|
|
255
|
+
# only what they leave missing.
|
|
256
|
+
force = args.force and interactive
|
|
257
|
+
enabled = _enabled_choice(storage, interactive, force=force)
|
|
258
|
+
task_kind = args.task_id_format
|
|
259
|
+
if task_kind is None and interactive and not _configured_task_format(storage):
|
|
260
|
+
print(
|
|
261
|
+
"Choose the default task ID format:\n"
|
|
262
|
+
" uuid TASK-<unique UUID> — unique across projects (default)\n"
|
|
263
|
+
" digit TASK-1, TASK-2, ... — sequential project numbers\n"
|
|
264
|
+
" timestamp TASK-<timestamp> — based on the creation time\n"
|
|
265
|
+
)
|
|
266
|
+
task_kind = _ask_choice(
|
|
267
|
+
_init_prompt(5, "Task ID format [uuid/digit/timestamp] (uuid): "),
|
|
268
|
+
("uuid", "digit", "timestamp"),
|
|
269
|
+
"uuid",
|
|
270
|
+
)
|
|
271
|
+
workflows = "modes: []\nhandlers: []\nhooks: {}\nworkflows: []\n"
|
|
272
|
+
|
|
273
|
+
# Every setting with its default, the answers written over them.
|
|
274
|
+
project = default_settings()
|
|
275
|
+
# The user or local level's own ``enabled`` stands, and a format another
|
|
276
|
+
# level already provides is not repeated in the repo file.
|
|
277
|
+
if enabled is None:
|
|
278
|
+
del project["enabled"]
|
|
279
|
+
else:
|
|
280
|
+
project["enabled"] = enabled
|
|
281
|
+
if task_kind is not None or not _configured_task_format(storage):
|
|
282
|
+
project["task_format"] = "TASK-{{" + (task_kind or "uuid") + "}}"
|
|
283
|
+
else:
|
|
284
|
+
del project["task_format"]
|
|
285
|
+
# A setting init does not ask about, which the user or local level
|
|
286
|
+
# already sets, keeps that value rather than a default over it.
|
|
287
|
+
for name, raw in _settings_by_level(storage).items():
|
|
288
|
+
if name != "repo":
|
|
289
|
+
for key in _UNASKED_SETTINGS & raw.keys():
|
|
290
|
+
project.pop(key, None)
|
|
291
|
+
has_git = (storage.root / ".git").exists()
|
|
292
|
+
if has_git:
|
|
293
|
+
existing_git = _existing_git_settings(storage)
|
|
294
|
+
existing_worktrees = existing_git.get("worktrees")
|
|
295
|
+
worktrees = (
|
|
296
|
+
existing_worktrees
|
|
297
|
+
if isinstance(existing_worktrees, bool)
|
|
298
|
+
else args.worktrees
|
|
299
|
+
)
|
|
300
|
+
if worktrees is None and interactive:
|
|
301
|
+
worktrees = _ask_yes_no(
|
|
302
|
+
_init_prompt(15, "Use Git worktrees? [y/N]: "), False
|
|
303
|
+
)
|
|
304
|
+
worktrees = bool(worktrees)
|
|
305
|
+
existing_directory = existing_git.get("worktree_dir")
|
|
306
|
+
directory = (
|
|
307
|
+
existing_directory
|
|
308
|
+
if isinstance(existing_directory, str) and existing_directory
|
|
309
|
+
else args.worktree_dir
|
|
310
|
+
)
|
|
311
|
+
if worktrees and directory is None and interactive:
|
|
312
|
+
directory = _ask_directory(
|
|
313
|
+
_init_prompt(25, "Worktree directory (./git-worktrees): ")
|
|
314
|
+
)
|
|
315
|
+
directory = directory or "./git-worktrees"
|
|
316
|
+
formats = {"default": "feature/{{ww.task.id}}"}
|
|
317
|
+
existing_formats = existing_git.get("branch_name_formats")
|
|
318
|
+
if isinstance(existing_formats, dict):
|
|
319
|
+
formats.update(existing_formats)
|
|
320
|
+
formats.update(_branch_format_arguments(args.branch_format))
|
|
321
|
+
if interactive:
|
|
322
|
+
for workflow in _workflow_names(storage):
|
|
323
|
+
if workflow in formats:
|
|
324
|
+
continue
|
|
325
|
+
value = input(
|
|
326
|
+
_init_prompt(
|
|
327
|
+
35, f"Branch format for workflow {workflow!r} (blank to skip): "
|
|
328
|
+
)
|
|
329
|
+
).strip()
|
|
330
|
+
if value:
|
|
331
|
+
formats[workflow] = value
|
|
332
|
+
git: dict[str, object] = {
|
|
333
|
+
"commit_format": "{{ww.task.id}}: {{commit_message}}",
|
|
334
|
+
"base_branches": {"default": _git_base_branch(storage.root)},
|
|
335
|
+
"separate_branch": True,
|
|
336
|
+
"branch_name_formats": formats,
|
|
337
|
+
"worktrees": worktrees,
|
|
338
|
+
}
|
|
339
|
+
if worktrees:
|
|
340
|
+
git.update(
|
|
341
|
+
{
|
|
342
|
+
"worktree_dir": directory,
|
|
343
|
+
"worktree_name_format": "{{ww.task.id}}",
|
|
344
|
+
}
|
|
345
|
+
)
|
|
346
|
+
project["extensions"] = {GIT_EXTENSION: git}
|
|
347
|
+
|
|
348
|
+
ignore_runtime = args.update_gitignore
|
|
349
|
+
# ``--force`` asks every question again, as if nothing were remembered.
|
|
350
|
+
choices = {} if force else _init_choices(storage)
|
|
351
|
+
ignore_path = storage.root / ".gitignore"
|
|
352
|
+
ignored = ignore_path.is_file() and runtime_ignored(
|
|
353
|
+
ignore_path.read_text(encoding="utf-8")
|
|
354
|
+
)
|
|
355
|
+
if ignore_runtime is None:
|
|
356
|
+
ignore_runtime = True if ignored else choices.get("update_gitignore")
|
|
357
|
+
if has_git and ignore_runtime is None and interactive:
|
|
358
|
+
ignore_runtime = _ask_yes_no(
|
|
359
|
+
_init_prompt(45, "Create or update .gitignore to exclude .ww/? [Y/n]: "),
|
|
360
|
+
True,
|
|
361
|
+
)
|
|
362
|
+
if ignore_runtime is None:
|
|
363
|
+
ignore_runtime = True
|
|
364
|
+
if isinstance(ignore_runtime, bool):
|
|
365
|
+
_save_init_choice(storage, "update_gitignore", ignore_runtime)
|
|
366
|
+
if args.link_instructions is None:
|
|
367
|
+
linked = choices.get("link_instructions")
|
|
368
|
+
if _instructions_linked(storage):
|
|
369
|
+
args.link_instructions = True
|
|
370
|
+
elif isinstance(linked, bool):
|
|
371
|
+
args.link_instructions = linked
|
|
372
|
+
if args.link_instructions is None and interactive:
|
|
373
|
+
print(
|
|
374
|
+
"\nWW_AGENT_INSTRUCTIONS.md tells your agents how to work through ww:\n"
|
|
375
|
+
"to start from `./ww discover`, follow each response, and keep going\n"
|
|
376
|
+
"until the workflow is done. Referencing it from the instruction files\n"
|
|
377
|
+
"your agents already read (AGENTS.md, and CLAUDE.md or GEMINI.md where\n"
|
|
378
|
+
"those agents are set up) means they pick it up on their own, so you\n"
|
|
379
|
+
"can just ask for the work instead of explaining ww every session.\n"
|
|
380
|
+
"\n"
|
|
381
|
+
"Answer no to keep those files untouched; you can add the one-line\n"
|
|
382
|
+
"reference yourself later, or name ww in the request when you want it.\n"
|
|
383
|
+
)
|
|
384
|
+
args.link_instructions = _ask_yes_no(
|
|
385
|
+
_init_prompt(
|
|
386
|
+
50, "Add @WW_AGENT_INSTRUCTIONS.md to agent instruction files? [Y/n]: "
|
|
387
|
+
),
|
|
388
|
+
True,
|
|
389
|
+
)
|
|
390
|
+
if isinstance(args.link_instructions, bool):
|
|
391
|
+
_save_init_choice(storage, "link_instructions", args.link_instructions)
|
|
392
|
+
return (
|
|
393
|
+
workflows,
|
|
394
|
+
json.dumps(project, indent=2) + "\n",
|
|
395
|
+
bool(ignore_runtime),
|
|
396
|
+
_skill_installs(storage, args.skills, interactive, progress=True, force=force),
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
# The settings init writes with their defaults without asking about them.
|
|
401
|
+
_UNASKED_SETTINGS = frozenset(
|
|
402
|
+
{"runtime", "update_check", "limits", "rules", "builtins", "workflows", "projects"}
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
# The ``enabled`` values as the operator types them.
|
|
407
|
+
_ENABLED_ANSWERS: dict[str, Enabled] = {
|
|
408
|
+
"true": True,
|
|
409
|
+
ON_REQUEST: ON_REQUEST,
|
|
410
|
+
"false": False,
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _enabled_value(value: object) -> Enabled | None:
|
|
415
|
+
if isinstance(value, bool):
|
|
416
|
+
return value
|
|
417
|
+
return ON_REQUEST if value == ON_REQUEST else None
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _enabled_choice(
|
|
421
|
+
storage: Storage, interactive: bool, *, force: bool = False
|
|
422
|
+
) -> Enabled | None:
|
|
423
|
+
"""Whether agents use ww here by default, only on request, or never.
|
|
424
|
+
|
|
425
|
+
The repo file's own ``enabled`` stands. When only the user or local level
|
|
426
|
+
sets it, the repo file gets none, so a choice one person made is not
|
|
427
|
+
committed for the team, and nothing is asked: ``None``. Otherwise it is
|
|
428
|
+
asked once and the answer remembered; ``force`` ignores that remembered
|
|
429
|
+
answer. Without a terminal init writes ``true``.
|
|
430
|
+
"""
|
|
431
|
+
levels = _settings_by_level(storage)
|
|
432
|
+
own = _enabled_value(levels.get("repo", {}).get("enabled"))
|
|
433
|
+
if own is not None:
|
|
434
|
+
return own
|
|
435
|
+
if any(
|
|
436
|
+
_enabled_value(raw.get("enabled")) is not None
|
|
437
|
+
for name, raw in levels.items()
|
|
438
|
+
if name != "repo"
|
|
439
|
+
):
|
|
440
|
+
return None
|
|
441
|
+
remembered = None if force else _init_choices(storage).get("enabled")
|
|
442
|
+
value = _enabled_value(remembered)
|
|
443
|
+
if value is not None:
|
|
444
|
+
return value
|
|
445
|
+
if not interactive:
|
|
446
|
+
return True
|
|
447
|
+
print(
|
|
448
|
+
"Choose when agents use ww in this project:\n"
|
|
449
|
+
" true by default, for requests that carry out project work "
|
|
450
|
+
"(default)\n"
|
|
451
|
+
" on_request only when the user explicitly asks for ww\n"
|
|
452
|
+
" false never\n"
|
|
453
|
+
)
|
|
454
|
+
enabled = _ENABLED_ANSWERS[
|
|
455
|
+
_ask_choice(
|
|
456
|
+
_init_prompt(2, "Use ww [true/on_request/false] (true): "),
|
|
457
|
+
tuple(_ENABLED_ANSWERS),
|
|
458
|
+
"true",
|
|
459
|
+
)
|
|
460
|
+
]
|
|
461
|
+
_save_init_choice(storage, "enabled", enabled)
|
|
462
|
+
return enabled
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def _skill_location(directory: str) -> str:
|
|
466
|
+
"""The ``ww`` skill, whose presence marks a directory as already set up."""
|
|
467
|
+
return skill_location(directory, WW_SKILL_NAME)
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
# "ww skills (ww, noww, ...)": every bundled skill, named in one phrase.
|
|
471
|
+
_SKILL_NAMES = f"ww skills ({', '.join(SKILLS)})"
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def _agent_directories(storage: Storage) -> tuple[str, ...]:
|
|
475
|
+
"""Agent directories from discovery that exist in this project."""
|
|
476
|
+
return tuple(
|
|
477
|
+
directory
|
|
478
|
+
for directory in _known_agent_directories()
|
|
479
|
+
if (storage.root / directory).is_dir()
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def _known_agent_directories() -> tuple[str, ...]:
|
|
484
|
+
"""Shared agent skills plus each integration-specific settings directory."""
|
|
485
|
+
return (".agents", *dict.fromkeys(AGENT_DIRECTORIES.values()))
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def _skill_installs(
|
|
489
|
+
storage: Storage,
|
|
490
|
+
requested: bool | None,
|
|
491
|
+
interactive: bool,
|
|
492
|
+
*,
|
|
493
|
+
progress: bool = False,
|
|
494
|
+
force: bool = False,
|
|
495
|
+
) -> tuple[tuple[str, str], ...]:
|
|
496
|
+
"""Pair each chosen agent directory with each skill it should hold.
|
|
497
|
+
|
|
498
|
+
The directories are chosen once and remembered. The skills are the
|
|
499
|
+
bundled ones the operator accepted, also remembered, so a skill that a
|
|
500
|
+
later ww version bundles is offered once on its own, into the directories
|
|
501
|
+
already chosen, without choosing agents again. ``force`` asks both again,
|
|
502
|
+
offering only what is not installed yet: nothing is ever removed.
|
|
503
|
+
"""
|
|
504
|
+
saved = _init_choices(storage).get("agents", {})
|
|
505
|
+
fresh = not any(
|
|
506
|
+
isinstance(saved, dict) and isinstance(saved.get(directory), bool)
|
|
507
|
+
for directory in _known_agent_directories()
|
|
508
|
+
)
|
|
509
|
+
directories = _skill_directories(
|
|
510
|
+
storage, requested, interactive, progress=progress, force=force
|
|
511
|
+
)
|
|
512
|
+
names = _accepted_skills(
|
|
513
|
+
storage,
|
|
514
|
+
directories,
|
|
515
|
+
requested,
|
|
516
|
+
interactive,
|
|
517
|
+
fresh=fresh,
|
|
518
|
+
progress=progress,
|
|
519
|
+
force=force,
|
|
520
|
+
)
|
|
521
|
+
return tuple((directory, name) for directory in directories for name in names)
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def _accepted_skills(
|
|
525
|
+
storage: Storage,
|
|
526
|
+
directories: tuple[str, ...],
|
|
527
|
+
requested: bool | None,
|
|
528
|
+
interactive: bool,
|
|
529
|
+
*,
|
|
530
|
+
fresh: bool,
|
|
531
|
+
progress: bool,
|
|
532
|
+
force: bool = False,
|
|
533
|
+
) -> tuple[str, ...]:
|
|
534
|
+
"""The bundled skills to install, asking only about ones never offered.
|
|
535
|
+
|
|
536
|
+
A first run offers every bundled skill through the directory question. A
|
|
537
|
+
project set up before skills were remembered counts a skill found in a
|
|
538
|
+
chosen directory as accepted. Either answer to a new skill is remembered,
|
|
539
|
+
so it is asked about once. ``force`` forgets the answers: a skill found
|
|
540
|
+
in a chosen directory stays accepted, and the others are offered again,
|
|
541
|
+
unless none is installed anywhere, when the directory question offered
|
|
542
|
+
them all.
|
|
543
|
+
"""
|
|
544
|
+
if requested is False or not directories:
|
|
545
|
+
return ()
|
|
546
|
+
saved = None if force else _init_choices(storage).get("skills")
|
|
547
|
+
decided: dict[str, bool] = (
|
|
548
|
+
{name: value for name, value in saved.items() if isinstance(value, bool)}
|
|
549
|
+
if isinstance(saved, dict)
|
|
550
|
+
else {}
|
|
551
|
+
)
|
|
552
|
+
if not isinstance(saved, dict):
|
|
553
|
+
present = {
|
|
554
|
+
name
|
|
555
|
+
for name in SKILLS
|
|
556
|
+
for directory in directories
|
|
557
|
+
if (storage.root / skill_location(directory, name)).exists()
|
|
558
|
+
}
|
|
559
|
+
fresh = fresh or (force and not present)
|
|
560
|
+
decided = {
|
|
561
|
+
name: True for name in SKILLS if fresh or requested or name in present
|
|
562
|
+
}
|
|
563
|
+
new = [name for name in SKILLS if name not in decided]
|
|
564
|
+
if new:
|
|
565
|
+
accept = True
|
|
566
|
+
if interactive and not requested:
|
|
567
|
+
label = ", ".join(f"`{name}`" for name in new)
|
|
568
|
+
plural = "s" if len(new) > 1 else ""
|
|
569
|
+
offer = (
|
|
570
|
+
f"Also install the {label} skill{plural}"
|
|
571
|
+
if force
|
|
572
|
+
else f"ww now ships the {label} skill{plural}. Install"
|
|
573
|
+
)
|
|
574
|
+
accept = _ask_yes_no(
|
|
575
|
+
_progress(
|
|
576
|
+
progress,
|
|
577
|
+
55,
|
|
578
|
+
f"{offer} into {', '.join(directories)}? [Y/n]: ",
|
|
579
|
+
),
|
|
580
|
+
True,
|
|
581
|
+
)
|
|
582
|
+
decided.update({name: accept for name in new})
|
|
583
|
+
_save_init_choice(storage, "skills", decided)
|
|
584
|
+
return tuple(name for name in SKILLS if decided.get(name))
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def _skill_directories(
|
|
588
|
+
storage: Storage,
|
|
589
|
+
requested: bool | None,
|
|
590
|
+
interactive: bool,
|
|
591
|
+
*,
|
|
592
|
+
progress: bool = False,
|
|
593
|
+
force: bool = False,
|
|
594
|
+
) -> tuple[str, ...]:
|
|
595
|
+
"""Choose the agent directories that receive the bundled skills.
|
|
596
|
+
|
|
597
|
+
Directories already holding the ``ww`` skill are included so
|
|
598
|
+
initialization reports their files as preserved and adds any skill that
|
|
599
|
+
is missing; storage never overwrites them. ``force`` asks again about
|
|
600
|
+
every other directory, whatever was answered before.
|
|
601
|
+
"""
|
|
602
|
+
saved = _init_choices(storage).get("agents", {})
|
|
603
|
+
choices = dict(saved) if isinstance(saved, dict) else {}
|
|
604
|
+
chosen_directories: list[str] = []
|
|
605
|
+
directories = _known_agent_directories()
|
|
606
|
+
undecided: list[tuple[str, bool]] = []
|
|
607
|
+
for directory in directories:
|
|
608
|
+
location = _skill_location(directory)
|
|
609
|
+
exists = (storage.root / directory).is_dir()
|
|
610
|
+
installed = (storage.root / location).exists()
|
|
611
|
+
selected = None if force else choices.get(directory)
|
|
612
|
+
if requested is False:
|
|
613
|
+
selected = False
|
|
614
|
+
elif installed or (requested and exists):
|
|
615
|
+
selected = True
|
|
616
|
+
elif not isinstance(selected, bool) and interactive:
|
|
617
|
+
# Every open question is settled in one place below, so the
|
|
618
|
+
# operator answers a single screen instead of one prompt per agent.
|
|
619
|
+
undecided.append((directory, exists))
|
|
620
|
+
selected = None
|
|
621
|
+
if isinstance(selected, bool):
|
|
622
|
+
choices[directory] = selected
|
|
623
|
+
_save_init_choice(storage, "agents", choices)
|
|
624
|
+
if selected is True:
|
|
625
|
+
chosen_directories.append(directory)
|
|
626
|
+
if undecided:
|
|
627
|
+
chosen = _choose_agent_directories(undecided, progress)
|
|
628
|
+
for directory, _ in undecided:
|
|
629
|
+
choices[directory] = directory in chosen
|
|
630
|
+
if directory in chosen:
|
|
631
|
+
chosen_directories.append(directory)
|
|
632
|
+
_save_init_choice(storage, "agents", choices)
|
|
633
|
+
return tuple(chosen_directories)
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
def _choose_agent_directories(
|
|
637
|
+
undecided: list[tuple[str, bool]], progress: bool
|
|
638
|
+
) -> set[str]:
|
|
639
|
+
"""Settle every open agent-directory question, in one screen where we can.
|
|
640
|
+
|
|
641
|
+
A terminal we can redraw gets a checklist. A pipe, a dumb terminal, or a
|
|
642
|
+
captured stdin gets plain questions instead: one for each directory that
|
|
643
|
+
already exists, and one shared question for the agents without one.
|
|
644
|
+
"""
|
|
645
|
+
if _interactive_terminal():
|
|
646
|
+
return set(
|
|
647
|
+
_ask_checklist(
|
|
648
|
+
f"\nInstall the {_SKILL_NAMES} into which agent directories?",
|
|
649
|
+
tuple(
|
|
650
|
+
(directory, "already present" if exists else "", exists)
|
|
651
|
+
for directory, exists in undecided
|
|
652
|
+
),
|
|
653
|
+
)
|
|
654
|
+
)
|
|
655
|
+
chosen = {
|
|
656
|
+
directory
|
|
657
|
+
for directory, exists in undecided
|
|
658
|
+
if exists
|
|
659
|
+
and _ask_yes_no(
|
|
660
|
+
_progress(
|
|
661
|
+
progress,
|
|
662
|
+
55,
|
|
663
|
+
f"Install the {_SKILL_NAMES} into {directory}/skills? [Y/n]: ",
|
|
664
|
+
),
|
|
665
|
+
True,
|
|
666
|
+
)
|
|
667
|
+
}
|
|
668
|
+
absent = [directory for directory, exists in undecided if not exists]
|
|
669
|
+
if absent:
|
|
670
|
+
print("\nNo directory exists yet for these agents. ww can create one and")
|
|
671
|
+
print("install its skill, so you can ask the agent to work through ww:")
|
|
672
|
+
for row in _columns(absent, shutil.get_terminal_size((80, 24))[0] - 2):
|
|
673
|
+
print(" " + row)
|
|
674
|
+
print()
|
|
675
|
+
chosen.update(
|
|
676
|
+
_ask_names(
|
|
677
|
+
_progress(progress, 90, "Create which? (comma-separated, or none): "),
|
|
678
|
+
tuple(absent),
|
|
679
|
+
)
|
|
680
|
+
)
|
|
681
|
+
return chosen
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
def _ask_directory(prompt: str) -> str | None:
|
|
685
|
+
"""Read a directory path, refusing an answer to the previous question.
|
|
686
|
+
|
|
687
|
+
This prompt follows a ``[y/N]`` one, and a stray ``y`` accepted as the
|
|
688
|
+
directory's name would create a directory called ``y`` and record it in
|
|
689
|
+
the project configuration without complaint.
|
|
690
|
+
"""
|
|
691
|
+
while True:
|
|
692
|
+
value = input(prompt).strip()
|
|
693
|
+
if not value:
|
|
694
|
+
return None
|
|
695
|
+
if value.lower() in {"y", "yes", "n", "no"}:
|
|
696
|
+
print("That looks like an answer to the previous question.")
|
|
697
|
+
print("Enter a directory path, or press Enter for ./git-worktrees.")
|
|
698
|
+
continue
|
|
699
|
+
return value
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
def _columns(names: list[str], width: int) -> list[str]:
|
|
703
|
+
"""Lay the names out in rows that fit, so a narrow terminal stays readable."""
|
|
704
|
+
rows: list[str] = []
|
|
705
|
+
row = ""
|
|
706
|
+
for name in names:
|
|
707
|
+
candidate = f"{row} {name}" if row else name
|
|
708
|
+
if row and len(candidate) > width:
|
|
709
|
+
rows.append(row)
|
|
710
|
+
row = name
|
|
711
|
+
else:
|
|
712
|
+
row = candidate
|
|
713
|
+
if row:
|
|
714
|
+
rows.append(row)
|
|
715
|
+
return rows
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
def _progress(progress: bool, percent: int, question: str) -> str:
|
|
719
|
+
return _init_prompt(percent, question) if progress else question
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
def _settings_by_level(storage: Storage) -> dict[str, dict[str, object]]:
|
|
723
|
+
"""Each settings level's own JSON object, by level name, where readable."""
|
|
724
|
+
levels: dict[str, dict[str, object]] = {}
|
|
725
|
+
for level in settings_levels(storage.project_config_path):
|
|
726
|
+
if not level.path.is_file():
|
|
727
|
+
continue
|
|
728
|
+
try:
|
|
729
|
+
raw = json.loads(level.path.read_text(encoding="utf-8"))
|
|
730
|
+
except (OSError, json.JSONDecodeError):
|
|
731
|
+
continue
|
|
732
|
+
if isinstance(raw, dict):
|
|
733
|
+
levels[level.name] = raw
|
|
734
|
+
return levels
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
def _configured_task_format(storage: Storage) -> bool:
|
|
738
|
+
"""Whether a settings level, user to local, already sets ``task_format``."""
|
|
739
|
+
try:
|
|
740
|
+
raw, _ = compose_settings(storage.project_config_path)
|
|
741
|
+
except ConfigurationError:
|
|
742
|
+
return False
|
|
743
|
+
return bool(raw.get("task_format"))
|
|
744
|
+
|
|
745
|
+
|
|
746
|
+
def _existing_git_settings(storage: Storage) -> dict[str, object]:
|
|
747
|
+
if not storage.project_config_path.is_file():
|
|
748
|
+
return {}
|
|
749
|
+
try:
|
|
750
|
+
raw = json.loads(storage.project_config_path.read_text(encoding="utf-8"))
|
|
751
|
+
extensions = raw.get("extensions", {})
|
|
752
|
+
settings = extensions.get(GIT_EXTENSION, {})
|
|
753
|
+
except (AttributeError, OSError, json.JSONDecodeError):
|
|
754
|
+
return {}
|
|
755
|
+
return settings if isinstance(settings, dict) else {}
|
|
756
|
+
|
|
757
|
+
|
|
758
|
+
def _workflow_names(storage: Storage) -> tuple[str, ...]:
|
|
759
|
+
if not storage.config_path.is_file():
|
|
760
|
+
return ()
|
|
761
|
+
try:
|
|
762
|
+
raw = compose_configuration(storage.config_path).raw
|
|
763
|
+
except (OSError, ConfigurationError):
|
|
764
|
+
return ()
|
|
765
|
+
if not isinstance(raw.get("workflows"), list):
|
|
766
|
+
return ()
|
|
767
|
+
names: list[str] = []
|
|
768
|
+
for item in raw["workflows"]:
|
|
769
|
+
if isinstance(item, dict):
|
|
770
|
+
name = item.get("name")
|
|
771
|
+
if not isinstance(name, str) and len(item) == 1:
|
|
772
|
+
name = next(iter(item))
|
|
773
|
+
if isinstance(name, str):
|
|
774
|
+
names.append(name)
|
|
775
|
+
return tuple(names)
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
def _branch_format_arguments(values: list[str]) -> dict[str, str]:
|
|
779
|
+
formats: dict[str, str] = {}
|
|
780
|
+
for value in values:
|
|
781
|
+
workflow, separator, branch_format = value.partition("=")
|
|
782
|
+
if not separator or not workflow.strip() or not branch_format.strip():
|
|
783
|
+
raise StateError("--branch-format must use WORKFLOW=FORMAT")
|
|
784
|
+
formats[workflow.strip()] = branch_format.strip()
|
|
785
|
+
return formats
|
|
786
|
+
|
|
787
|
+
|
|
788
|
+
def _git_base_branch(root: Path) -> str:
|
|
789
|
+
for branch in ("master", "main"):
|
|
790
|
+
reference_result = subprocess.run(
|
|
791
|
+
["git", "show-ref", "--verify", "--quiet", f"refs/heads/{branch}"],
|
|
792
|
+
cwd=root,
|
|
793
|
+
check=False,
|
|
794
|
+
)
|
|
795
|
+
if reference_result.returncode == 0:
|
|
796
|
+
return branch
|
|
797
|
+
result = subprocess.run(
|
|
798
|
+
["git", "symbolic-ref", "--quiet", "--short", "HEAD"],
|
|
799
|
+
cwd=root,
|
|
800
|
+
capture_output=True,
|
|
801
|
+
text=True,
|
|
802
|
+
check=False,
|
|
803
|
+
)
|
|
804
|
+
current = result.stdout.strip()
|
|
805
|
+
return current if current in {"master", "main"} else "main"
|
|
806
|
+
|
|
807
|
+
|
|
808
|
+
def _finish_initialization(
|
|
809
|
+
storage: Storage,
|
|
810
|
+
result: InitializationResult,
|
|
811
|
+
*,
|
|
812
|
+
shown: bool = True,
|
|
813
|
+
force: bool = False,
|
|
814
|
+
) -> InitializationResult:
|
|
815
|
+
"""Complete the result; ``shown`` means the operator reads the summary.
|
|
816
|
+
|
|
817
|
+
``force`` shows the permission notice again even when it was shown.
|
|
818
|
+
"""
|
|
819
|
+
created = list(result.created)
|
|
820
|
+
actions = list(result.actions)
|
|
821
|
+
try:
|
|
822
|
+
raw = json.loads(storage.project_config_path.read_text(encoding="utf-8"))
|
|
823
|
+
git = raw.get("extensions", {}).get(GIT_EXTENSION, {})
|
|
824
|
+
configured = raw.get("executable")
|
|
825
|
+
except (AttributeError, OSError, json.JSONDecodeError):
|
|
826
|
+
git, configured = {}, None
|
|
827
|
+
executable = (
|
|
828
|
+
configured.strip()
|
|
829
|
+
if isinstance(configured, str) and configured.strip()
|
|
830
|
+
else DEFAULT_EXECUTABLE
|
|
831
|
+
)
|
|
832
|
+
if isinstance(git, dict) and git.get("worktrees") is True:
|
|
833
|
+
configured = git.get("worktree_dir")
|
|
834
|
+
if isinstance(configured, str) and configured:
|
|
835
|
+
directory = Path(configured)
|
|
836
|
+
if not directory.is_absolute():
|
|
837
|
+
directory = storage.root / directory
|
|
838
|
+
if not directory.exists():
|
|
839
|
+
directory.mkdir(parents=True)
|
|
840
|
+
try:
|
|
841
|
+
label = str(directory.relative_to(storage.root))
|
|
842
|
+
except ValueError:
|
|
843
|
+
label = str(directory)
|
|
844
|
+
created.append(label)
|
|
845
|
+
path = storage.root / ".gitignore"
|
|
846
|
+
ignored = path.is_file() and runtime_ignored(path.read_text(encoding="utf-8"))
|
|
847
|
+
if not ignored and (storage.root / ".git").exists():
|
|
848
|
+
actions.append("Optionally keep .ww out of Git with `init --update-gitignore`.")
|
|
849
|
+
missing = [
|
|
850
|
+
directory
|
|
851
|
+
for directory in _agent_directories(storage)
|
|
852
|
+
if not (storage.root / _skill_location(directory)).exists()
|
|
853
|
+
]
|
|
854
|
+
if missing:
|
|
855
|
+
actions.append(
|
|
856
|
+
f"Optionally install the {_SKILL_NAMES} with `init --skills` "
|
|
857
|
+
"for: " + ", ".join(missing) + "."
|
|
858
|
+
)
|
|
859
|
+
# The permission notice matters once: show it the first time the summary
|
|
860
|
+
# is read, and remember that it was.
|
|
861
|
+
notice = force or _init_choices(storage).get("permission_notice_shown") is not True
|
|
862
|
+
if notice and shown:
|
|
863
|
+
_save_init_choice(storage, "permission_notice_shown", True)
|
|
864
|
+
commands = tuple(dict.fromkeys((executable, PROJECT_LAUNCHER_COMMAND, "ww")))
|
|
865
|
+
permissions, others = _agent_permissions(storage, commands)
|
|
866
|
+
return replace(
|
|
867
|
+
result,
|
|
868
|
+
created=tuple(created),
|
|
869
|
+
actions=tuple(actions),
|
|
870
|
+
permission_notice=notice,
|
|
871
|
+
executable=executable,
|
|
872
|
+
commands=commands,
|
|
873
|
+
permissions=permissions,
|
|
874
|
+
other_agents=others,
|
|
875
|
+
)
|
|
876
|
+
|
|
877
|
+
|
|
878
|
+
def _agent_permissions(
|
|
879
|
+
storage: Storage, commands: tuple[str, ...]
|
|
880
|
+
) -> tuple[tuple[tuple[str, str, str], ...], tuple[str, ...]]:
|
|
881
|
+
"""What each agent set up here needs to run ``commands`` without asking.
|
|
882
|
+
|
|
883
|
+
An agent counts as set up when its directory exists. For an agent whose
|
|
884
|
+
permission format ww knows, the answer is its permissions file and the
|
|
885
|
+
JSON to merge into it; every other agent is named for a generic line.
|
|
886
|
+
"""
|
|
887
|
+
known: list[tuple[str, str, str]] = []
|
|
888
|
+
others: list[str] = []
|
|
889
|
+
for name, directory in AGENT_DIRECTORIES.items():
|
|
890
|
+
if not (storage.root / directory).is_dir():
|
|
891
|
+
continue
|
|
892
|
+
agent = HOOK_AGENTS.get(name)
|
|
893
|
+
entries = agent.permissions(commands) if agent else None
|
|
894
|
+
if agent and agent.permissions_file and entries is not None:
|
|
895
|
+
known.append((name, agent.permissions_file, json.dumps(entries, indent=2)))
|
|
896
|
+
else:
|
|
897
|
+
others.append(name)
|
|
898
|
+
return tuple(known), tuple(others)
|