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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (167) hide show
  1. ww/__init__.py +18 -0
  2. ww/_bundled_extensions/ww/git/extension.py +1728 -0
  3. ww/action_execution.py +887 -0
  4. ww/actions/__init__.py +94 -0
  5. ww/actions/command.py +444 -0
  6. ww/actions/contracts.py +699 -0
  7. ww/actions/extension.py +197 -0
  8. ww/actions/mcp.py +84 -0
  9. ww/actions/prompt.py +74 -0
  10. ww/actions/skill.py +62 -0
  11. ww/actions/slash_command.py +63 -0
  12. ww/agents.py +151 -0
  13. ww/amendments.py +54 -0
  14. ww/artifacts.py +93 -0
  15. ww/assessments.py +181 -0
  16. ww/assets/__init__.py +2 -0
  17. ww/assets/agent_instructions.md +49 -0
  18. ww/assets/docs/examples.md +879 -0
  19. ww/assets/docs/features.md +4639 -0
  20. ww/assets/docs/specification.md +1876 -0
  21. ww/assets/noww_skill.md +11 -0
  22. ww/assets/workflows/catchall.yaml +26 -0
  23. ww/assets/workflows/onboarding.yaml +586 -0
  24. ww/assets/workflows/scriptize.yaml +130 -0
  25. ww/assets/ww-automate_skill.md +23 -0
  26. ww/assets/ww-deduce-feedback_skill.md +38 -0
  27. ww/assets/ww-feedback-rules_skill.md +48 -0
  28. ww/assets/ww-learn-project_skill.md +22 -0
  29. ww/assets/ww-refresh_skill.md +26 -0
  30. ww/assets/ww-rule_skill.md +83 -0
  31. ww/assets/ww-rules-from-artifacts_skill.md +22 -0
  32. ww/assets/ww-scriptize_skill.md +33 -0
  33. ww/assets/ww-setup_skill.md +94 -0
  34. ww/assets/ww-solve_skill.md +23 -0
  35. ww/assets/ww-suggest_skill.md +32 -0
  36. ww/assets/ww-wizard_skill.md +105 -0
  37. ww/assets/ww_skill.md +59 -0
  38. ww/assignments.py +283 -0
  39. ww/bootstrap.py +405 -0
  40. ww/builtin_workflows.py +215 -0
  41. ww/changes.py +225 -0
  42. ww/child_coordination.py +482 -0
  43. ww/children.py +106 -0
  44. ww/claude_permissions.py +115 -0
  45. ww/cli/__init__.py +7 -0
  46. ww/cli/__main__.py +6 -0
  47. ww/cli/audit.py +129 -0
  48. ww/cli/catalogs.py +131 -0
  49. ww/cli/discover.py +607 -0
  50. ww/cli/initialization.py +898 -0
  51. ww/cli/lookup.py +287 -0
  52. ww/cli/main.py +1768 -0
  53. ww/cli/parser.py +1200 -0
  54. ww/cli/prompts.py +217 -0
  55. ww/cli/updates.py +117 -0
  56. ww/completion_artifacts.py +156 -0
  57. ww/completion_inputs.py +39 -0
  58. ww/config/__init__.py +582 -0
  59. ww/config/actions.py +591 -0
  60. ww/config/composition.py +571 -0
  61. ww/config/rules.py +511 -0
  62. ww/config/steps.py +1220 -0
  63. ww/config/values.py +223 -0
  64. ww/config_files.py +191 -0
  65. ww/config_writes.py +264 -0
  66. ww/contracts.py +155 -0
  67. ww/control.py +41 -0
  68. ww/defaults.py +130 -0
  69. ww/design_docs.py +32 -0
  70. ww/discovery.py +104 -0
  71. ww/documents.py +217 -0
  72. ww/errors.py +18 -0
  73. ww/executable.py +43 -0
  74. ww/execution_models/__init__.py +64 -0
  75. ww/execution_models/construction.py +148 -0
  76. ww/execution_models/decoding.py +38 -0
  77. ww/execution_models/plan_codec.py +565 -0
  78. ww/execution_models/records.py +1206 -0
  79. ww/execution_models/runs.py +266 -0
  80. ww/extensions/__init__.py +40 -0
  81. ww/extensions/api.py +559 -0
  82. ww/extensions/registry.py +864 -0
  83. ww/extensions/store.py +78 -0
  84. ww/feedback.py +342 -0
  85. ww/handler_repairs.py +57 -0
  86. ww/hooks/__init__.py +40 -0
  87. ww/hooks/agents.py +380 -0
  88. ww/hooks/install.py +168 -0
  89. ww/hooks/notices.py +206 -0
  90. ww/hooks/records.py +209 -0
  91. ww/hooks/runtime.py +266 -0
  92. ww/hooks/transcripts.py +183 -0
  93. ww/inspect.py +896 -0
  94. ww/instructions/__init__.py +17 -0
  95. ww/instructions/builder.py +1682 -0
  96. ww/instructions/commands.py +335 -0
  97. ww/instructions/handoff.py +149 -0
  98. ww/instructions/models.py +686 -0
  99. ww/instructions/policy.py +219 -0
  100. ww/instructions/text.py +168 -0
  101. ww/interactions.py +187 -0
  102. ww/interpolation.py +37 -0
  103. ww/item_passes.py +167 -0
  104. ww/items.py +99 -0
  105. ww/locking.py +207 -0
  106. ww/metadata_publication.py +230 -0
  107. ww/onboarding.py +229 -0
  108. ww/open_work.py +236 -0
  109. ww/operations.py +193 -0
  110. ww/operator_ui/__init__.py +16 -0
  111. ww/operator_ui/page.html +351 -0
  112. ww/operator_ui/server.py +215 -0
  113. ww/operator_ui/session.py +389 -0
  114. ww/operator_ui/sheet.py +104 -0
  115. ww/operator_ui/view.py +109 -0
  116. ww/output.py +339 -0
  117. ww/output_adapters/__init__.py +12 -0
  118. ww/output_adapters/base.py +25 -0
  119. ww/output_adapters/json_adapter.py +37 -0
  120. ww/output_adapters/markdown.py +2293 -0
  121. ww/output_adapters/rule_pages.py +337 -0
  122. ww/output_adapters/terminal.py +21 -0
  123. ww/package_updates.py +167 -0
  124. ww/plan/__init__.py +38 -0
  125. ww/plan/actions.py +207 -0
  126. ww/plan/compiler.py +1492 -0
  127. ww/plan/constructs.py +456 -0
  128. ww/plan/models.py +665 -0
  129. ww/project_config.py +752 -0
  130. ww/recovery.py +401 -0
  131. ww/replanning.py +367 -0
  132. ww/results.py +77 -0
  133. ww/rule_checks.py +230 -0
  134. ww/rule_conversion.py +331 -0
  135. ww/rule_disputes.py +148 -0
  136. ww/rule_store.py +456 -0
  137. ww/rule_verification.py +714 -0
  138. ww/rule_views.py +447 -0
  139. ww/rule_writes.py +920 -0
  140. ww/run_coordination.py +158 -0
  141. ww/runtimes.py +105 -0
  142. ww/service.py +4405 -0
  143. ww/setup_apply.py +428 -0
  144. ww/step_values.py +20 -0
  145. ww/storage.py +447 -0
  146. ww/storage_adapters/__init__.py +36 -0
  147. ww/storage_adapters/base.py +540 -0
  148. ww/storage_adapters/filesystem.py +370 -0
  149. ww/storage_adapters/memory.py +195 -0
  150. ww/storage_adapters/project_metadata.py +69 -0
  151. ww/storage_adapters/task_document.py +484 -0
  152. ww/task_ids.py +114 -0
  153. ww/task_references.py +124 -0
  154. ww/transitions.py +1619 -0
  155. ww/updates.py +399 -0
  156. ww/upgrade.py +95 -0
  157. ww/validation.py +168 -0
  158. ww/variables.py +275 -0
  159. ww/workflow_config.py +854 -0
  160. ww/workflow_update.py +239 -0
  161. ww/workflow_validation.py +1260 -0
  162. ww/workspace.py +50 -0
  163. ww_agentic_workflows-1.0.0.dev3.dist-info/METADATA +690 -0
  164. ww_agentic_workflows-1.0.0.dev3.dist-info/RECORD +167 -0
  165. ww_agentic_workflows-1.0.0.dev3.dist-info/WHEEL +4 -0
  166. ww_agentic_workflows-1.0.0.dev3.dist-info/entry_points.txt +2 -0
  167. ww_agentic_workflows-1.0.0.dev3.dist-info/licenses/LICENSE +674 -0
@@ -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)