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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (167) hide show
  1. ww/__init__.py +18 -0
  2. ww/_bundled_extensions/ww/git/extension.py +1728 -0
  3. ww/action_execution.py +887 -0
  4. ww/actions/__init__.py +94 -0
  5. ww/actions/command.py +444 -0
  6. ww/actions/contracts.py +699 -0
  7. ww/actions/extension.py +197 -0
  8. ww/actions/mcp.py +84 -0
  9. ww/actions/prompt.py +74 -0
  10. ww/actions/skill.py +62 -0
  11. ww/actions/slash_command.py +63 -0
  12. ww/agents.py +151 -0
  13. ww/amendments.py +54 -0
  14. ww/artifacts.py +93 -0
  15. ww/assessments.py +181 -0
  16. ww/assets/__init__.py +2 -0
  17. ww/assets/agent_instructions.md +49 -0
  18. ww/assets/docs/examples.md +879 -0
  19. ww/assets/docs/features.md +4639 -0
  20. ww/assets/docs/specification.md +1876 -0
  21. ww/assets/noww_skill.md +11 -0
  22. ww/assets/workflows/catchall.yaml +26 -0
  23. ww/assets/workflows/onboarding.yaml +586 -0
  24. ww/assets/workflows/scriptize.yaml +130 -0
  25. ww/assets/ww-automate_skill.md +23 -0
  26. ww/assets/ww-deduce-feedback_skill.md +38 -0
  27. ww/assets/ww-feedback-rules_skill.md +48 -0
  28. ww/assets/ww-learn-project_skill.md +22 -0
  29. ww/assets/ww-refresh_skill.md +26 -0
  30. ww/assets/ww-rule_skill.md +83 -0
  31. ww/assets/ww-rules-from-artifacts_skill.md +22 -0
  32. ww/assets/ww-scriptize_skill.md +33 -0
  33. ww/assets/ww-setup_skill.md +94 -0
  34. ww/assets/ww-solve_skill.md +23 -0
  35. ww/assets/ww-suggest_skill.md +32 -0
  36. ww/assets/ww-wizard_skill.md +105 -0
  37. ww/assets/ww_skill.md +59 -0
  38. ww/assignments.py +283 -0
  39. ww/bootstrap.py +405 -0
  40. ww/builtin_workflows.py +215 -0
  41. ww/changes.py +225 -0
  42. ww/child_coordination.py +482 -0
  43. ww/children.py +106 -0
  44. ww/claude_permissions.py +115 -0
  45. ww/cli/__init__.py +7 -0
  46. ww/cli/__main__.py +6 -0
  47. ww/cli/audit.py +129 -0
  48. ww/cli/catalogs.py +131 -0
  49. ww/cli/discover.py +607 -0
  50. ww/cli/initialization.py +898 -0
  51. ww/cli/lookup.py +287 -0
  52. ww/cli/main.py +1768 -0
  53. ww/cli/parser.py +1200 -0
  54. ww/cli/prompts.py +217 -0
  55. ww/cli/updates.py +117 -0
  56. ww/completion_artifacts.py +156 -0
  57. ww/completion_inputs.py +39 -0
  58. ww/config/__init__.py +582 -0
  59. ww/config/actions.py +591 -0
  60. ww/config/composition.py +571 -0
  61. ww/config/rules.py +511 -0
  62. ww/config/steps.py +1220 -0
  63. ww/config/values.py +223 -0
  64. ww/config_files.py +191 -0
  65. ww/config_writes.py +264 -0
  66. ww/contracts.py +155 -0
  67. ww/control.py +41 -0
  68. ww/defaults.py +130 -0
  69. ww/design_docs.py +32 -0
  70. ww/discovery.py +104 -0
  71. ww/documents.py +217 -0
  72. ww/errors.py +18 -0
  73. ww/executable.py +43 -0
  74. ww/execution_models/__init__.py +64 -0
  75. ww/execution_models/construction.py +148 -0
  76. ww/execution_models/decoding.py +38 -0
  77. ww/execution_models/plan_codec.py +565 -0
  78. ww/execution_models/records.py +1206 -0
  79. ww/execution_models/runs.py +266 -0
  80. ww/extensions/__init__.py +40 -0
  81. ww/extensions/api.py +559 -0
  82. ww/extensions/registry.py +864 -0
  83. ww/extensions/store.py +78 -0
  84. ww/feedback.py +342 -0
  85. ww/handler_repairs.py +57 -0
  86. ww/hooks/__init__.py +40 -0
  87. ww/hooks/agents.py +380 -0
  88. ww/hooks/install.py +168 -0
  89. ww/hooks/notices.py +206 -0
  90. ww/hooks/records.py +209 -0
  91. ww/hooks/runtime.py +266 -0
  92. ww/hooks/transcripts.py +183 -0
  93. ww/inspect.py +896 -0
  94. ww/instructions/__init__.py +17 -0
  95. ww/instructions/builder.py +1682 -0
  96. ww/instructions/commands.py +335 -0
  97. ww/instructions/handoff.py +149 -0
  98. ww/instructions/models.py +686 -0
  99. ww/instructions/policy.py +219 -0
  100. ww/instructions/text.py +168 -0
  101. ww/interactions.py +187 -0
  102. ww/interpolation.py +37 -0
  103. ww/item_passes.py +167 -0
  104. ww/items.py +99 -0
  105. ww/locking.py +207 -0
  106. ww/metadata_publication.py +230 -0
  107. ww/onboarding.py +229 -0
  108. ww/open_work.py +236 -0
  109. ww/operations.py +193 -0
  110. ww/operator_ui/__init__.py +16 -0
  111. ww/operator_ui/page.html +351 -0
  112. ww/operator_ui/server.py +215 -0
  113. ww/operator_ui/session.py +389 -0
  114. ww/operator_ui/sheet.py +104 -0
  115. ww/operator_ui/view.py +109 -0
  116. ww/output.py +339 -0
  117. ww/output_adapters/__init__.py +12 -0
  118. ww/output_adapters/base.py +25 -0
  119. ww/output_adapters/json_adapter.py +37 -0
  120. ww/output_adapters/markdown.py +2293 -0
  121. ww/output_adapters/rule_pages.py +337 -0
  122. ww/output_adapters/terminal.py +21 -0
  123. ww/package_updates.py +167 -0
  124. ww/plan/__init__.py +38 -0
  125. ww/plan/actions.py +207 -0
  126. ww/plan/compiler.py +1492 -0
  127. ww/plan/constructs.py +456 -0
  128. ww/plan/models.py +665 -0
  129. ww/project_config.py +752 -0
  130. ww/recovery.py +401 -0
  131. ww/replanning.py +367 -0
  132. ww/results.py +77 -0
  133. ww/rule_checks.py +230 -0
  134. ww/rule_conversion.py +331 -0
  135. ww/rule_disputes.py +148 -0
  136. ww/rule_store.py +456 -0
  137. ww/rule_verification.py +714 -0
  138. ww/rule_views.py +447 -0
  139. ww/rule_writes.py +920 -0
  140. ww/run_coordination.py +158 -0
  141. ww/runtimes.py +105 -0
  142. ww/service.py +4405 -0
  143. ww/setup_apply.py +428 -0
  144. ww/step_values.py +20 -0
  145. ww/storage.py +447 -0
  146. ww/storage_adapters/__init__.py +36 -0
  147. ww/storage_adapters/base.py +540 -0
  148. ww/storage_adapters/filesystem.py +370 -0
  149. ww/storage_adapters/memory.py +195 -0
  150. ww/storage_adapters/project_metadata.py +69 -0
  151. ww/storage_adapters/task_document.py +484 -0
  152. ww/task_ids.py +114 -0
  153. ww/task_references.py +124 -0
  154. ww/transitions.py +1619 -0
  155. ww/updates.py +399 -0
  156. ww/upgrade.py +95 -0
  157. ww/validation.py +168 -0
  158. ww/variables.py +275 -0
  159. ww/workflow_config.py +854 -0
  160. ww/workflow_update.py +239 -0
  161. ww/workflow_validation.py +1260 -0
  162. ww/workspace.py +50 -0
  163. ww_agentic_workflows-1.0.0.dev3.dist-info/METADATA +690 -0
  164. ww_agentic_workflows-1.0.0.dev3.dist-info/RECORD +167 -0
  165. ww_agentic_workflows-1.0.0.dev3.dist-info/WHEEL +4 -0
  166. ww_agentic_workflows-1.0.0.dev3.dist-info/entry_points.txt +2 -0
  167. ww_agentic_workflows-1.0.0.dev3.dist-info/licenses/LICENSE +674 -0
ww/operations.py ADDED
@@ -0,0 +1,193 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ """Core workflow-control operations embedded in compiled plans.
3
+
4
+ This is a leaf module: it depends only on ``ww.contracts`` so that the
5
+ engine, the parser, the validator, and the plan compiler can all import it
6
+ without pulling in the ``ww.plan`` package.
7
+
8
+ Loops, workflow handoffs, and child-workflow runs are behaviour of the workflow
9
+ engine itself. Core code plans, persists, and dispatches them directly; they
10
+ are deliberately not part of the ordinary action registry and cannot be added
11
+ or replaced through it.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from dataclasses import dataclass
17
+ from typing import TYPE_CHECKING, Any, ClassVar, TypeAlias
18
+
19
+ from ww.contracts import ExecutionKind, LoopOperation, PlanItemOwner
20
+ from ww.validation import expect_keys, is_positive_int
21
+
22
+ if TYPE_CHECKING:
23
+ from ww.actions.contracts import PlannedAction
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class LoopBoundary:
28
+ """Manager-owned entry or repeat boundary generated for a ``loop`` step."""
29
+
30
+ kind: ClassVar[str] = "loop"
31
+ owner: ClassVar[PlanItemOwner] = "ww"
32
+ execution: ClassVar[ExecutionKind] = "loop_control"
33
+
34
+ loop_id: str
35
+ boundary: LoopOperation
36
+ max_times: int
37
+
38
+ def __post_init__(self) -> None:
39
+ if not isinstance(self.loop_id, str) or not self.loop_id.strip():
40
+ raise ValueError("loop boundary requires a non-empty loop ID")
41
+ if self.boundary not in {"enter", "repeat"}:
42
+ raise ValueError("loop boundary must be enter or repeat")
43
+ if not is_positive_int(self.max_times):
44
+ raise ValueError("loop boundary requires a positive integer limit")
45
+
46
+ def to_dict(self) -> dict[str, object]:
47
+ return {
48
+ "loop_id": self.loop_id,
49
+ "boundary": self.boundary,
50
+ "max_times": self.max_times,
51
+ }
52
+
53
+ @classmethod
54
+ def from_dict(cls, data: dict[str, Any]) -> LoopBoundary:
55
+ expect_keys(data, {"loop_id", "boundary", "max_times"}, f"{cls.kind} operation")
56
+ return cls(data["loop_id"], data["boundary"], data["max_times"])
57
+
58
+
59
+ @dataclass(frozen=True)
60
+ class WorkflowHandoff:
61
+ """Terminal transition of a handoff workflow into its selected successor."""
62
+
63
+ kind: ClassVar[str] = "workflow_transition"
64
+ owner: ClassVar[PlanItemOwner] = "ww"
65
+ execution: ClassVar[ExecutionKind] = "workflow_transition"
66
+
67
+ target: str
68
+
69
+ def __post_init__(self) -> None:
70
+ if not isinstance(self.target, str) or not self.target.strip():
71
+ raise ValueError("workflow handoff requires a non-empty target")
72
+
73
+ def to_dict(self) -> dict[str, object]:
74
+ return {"target": self.target}
75
+
76
+ @classmethod
77
+ def from_dict(cls, data: dict[str, Any]) -> WorkflowHandoff:
78
+ expect_keys(data, {"target"}, f"{cls.kind} operation")
79
+ return cls(data["target"])
80
+
81
+
82
+ CHILD_LAUNCH_SETTINGS = ("workflow", "runtime", "model", "reasoning", "agent")
83
+
84
+
85
+ @dataclass(frozen=True)
86
+ class ChildLaunch:
87
+ """The launch settings ww applies when it starts a child itself.
88
+
89
+ Each setting is a template rendered, when the stage runs, from the child's
90
+ record (``{{ww.child.field.<name>}}`` and the like). ``None`` or a value
91
+ that renders empty inherits, exactly as an omitted ``start-child`` option.
92
+ """
93
+
94
+ workflow: str | None = None
95
+ runtime: str | None = None
96
+ model: str | None = None
97
+ reasoning: str | None = None
98
+ agent: str | None = None
99
+
100
+ @property
101
+ def templates(self) -> tuple[str, ...]:
102
+ return tuple(
103
+ value
104
+ for name in CHILD_LAUNCH_SETTINGS
105
+ if isinstance(value := getattr(self, name), str)
106
+ )
107
+
108
+ def to_dict(self) -> dict[str, object]:
109
+ return {
110
+ name: value
111
+ for name in CHILD_LAUNCH_SETTINGS
112
+ if (value := getattr(self, name)) is not None
113
+ }
114
+
115
+ @classmethod
116
+ def from_dict(cls, data: object) -> ChildLaunch:
117
+ if not isinstance(data, dict) or not all(
118
+ key in CHILD_LAUNCH_SETTINGS and isinstance(value, str)
119
+ for key, value in data.items()
120
+ ):
121
+ raise ValueError("child launch must map launch settings to strings")
122
+ return cls(**data)
123
+
124
+
125
+ @dataclass(frozen=True)
126
+ class ChildWorkflowRun:
127
+ """Coordinator item that runs the named workflow once per collected child.
128
+
129
+ With ``launch`` ww starts the child itself when the item is reached.
130
+ """
131
+
132
+ kind: ClassVar[str] = "child_workflow"
133
+ owner: ClassVar[PlanItemOwner] = "ww"
134
+ execution: ClassVar[ExecutionKind] = "agent_instruction"
135
+
136
+ workflow: str
137
+ launch: ChildLaunch | None = None
138
+
139
+ def __post_init__(self) -> None:
140
+ if not isinstance(self.workflow, str) or not self.workflow.strip():
141
+ raise ValueError("child workflow requires a non-empty target")
142
+
143
+ def to_dict(self) -> dict[str, object]:
144
+ data: dict[str, object] = {"workflow": self.workflow}
145
+ if self.launch is not None:
146
+ data["launch"] = self.launch.to_dict()
147
+ return data
148
+
149
+ @classmethod
150
+ def from_dict(cls, data: dict[str, Any]) -> ChildWorkflowRun:
151
+ expect_keys(data, {"workflow"}, f"{cls.kind} operation")
152
+ launch = data.get("launch")
153
+ return cls(
154
+ data["workflow"],
155
+ ChildLaunch.from_dict(launch) if launch is not None else None,
156
+ )
157
+
158
+
159
+ CoreOperation: TypeAlias = "LoopBoundary | WorkflowHandoff | ChildWorkflowRun"
160
+ PlanOperation: TypeAlias = "PlannedAction | CoreOperation"
161
+
162
+ _CORE_OPERATIONS: dict[str, type[LoopBoundary | WorkflowHandoff | ChildWorkflowRun]] = {
163
+ LoopBoundary.kind: LoopBoundary,
164
+ WorkflowHandoff.kind: WorkflowHandoff,
165
+ ChildWorkflowRun.kind: ChildWorkflowRun,
166
+ }
167
+ _ACTION_TYPE = "action"
168
+
169
+
170
+ def encode_operation(operation: PlanOperation) -> dict[str, object]:
171
+ """Serialize a plan operation with an explicit ``type`` discriminator."""
172
+ # Runtime import: ``ww.actions`` must stay independent of core operations.
173
+ from ww.actions.contracts import PlannedAction
174
+
175
+ if isinstance(operation, PlannedAction):
176
+ return {"type": _ACTION_TYPE, **operation.to_dict()}
177
+ return {"type": operation.kind, **operation.to_dict()}
178
+
179
+
180
+ def decode_operation(data: object) -> PlanOperation:
181
+ """Rebuild a plan operation from its serialized form."""
182
+ if not isinstance(data, dict) or not isinstance(data.get("type"), str):
183
+ raise ValueError("plan operation must declare a type")
184
+ fields = {key: value for key, value in data.items() if key != "type"}
185
+ if data["type"] == _ACTION_TYPE:
186
+ from ww.actions.contracts import PlannedAction
187
+
188
+ return PlannedAction.from_dict(fields)
189
+ try:
190
+ operation_type = _CORE_OPERATIONS[data["type"]]
191
+ except KeyError:
192
+ raise ValueError(f"unknown plan operation type: {data['type']!r}") from None
193
+ return operation_type.from_dict(fields)
@@ -0,0 +1,16 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ """The operator page: a local web page the operator answers items on.
3
+
4
+ This package is an extra on top of the engine, not part of it. The core
5
+ knows one thing about it: a per-item stage may be declared
6
+ ``interactive: page``, and the step's page then tells the agent to run
7
+ ``interact --await``. Everything
8
+ else lives here. The page is served for exactly as long as that command
9
+ waits; answers wait in a file of their own, and once the wait is over they
10
+ are applied through the same public service calls an agent would make:
11
+ ``interact``, ``update_item``, ``complete``, and ``next``.
12
+ """
13
+
14
+ from .session import OperatorPageResult, run_operator_page
15
+
16
+ __all__ = ["OperatorPageResult", "run_operator_page"]
@@ -0,0 +1,351 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>ww operator page</title>
7
+ <style>
8
+ :root {
9
+ --bg: #f6f7f9; --panel: #ffffff; --ink: #1c1f24; --muted: #6b7280;
10
+ --line: #e3e6ea; --accent: #2563eb; --accent-ink: #ffffff;
11
+ --open: #fff7d6; --answered: #e8f0ff; --processed: #e8f5ec; --danger: #b91c1c;
12
+ --warn-bg: #fdf8e8; --warn-line: #e6d18a; --warn-ink: #6b5200; --warn-mark: #d4a017;
13
+ }
14
+ @media (prefers-color-scheme: dark) {
15
+ :root {
16
+ --bg: #14161a; --panel: #1e2126; --ink: #e6e8eb; --muted: #9aa3ad;
17
+ --line: #30353c; --accent: #3b82f6; --accent-ink: #ffffff;
18
+ --open: #3a3410; --answered: #1e2a4a; --processed: #163120; --danger: #f87171;
19
+ --warn-bg: #2b2513; --warn-line: #5c4c1c; --warn-ink: #e8d9a8; --warn-mark: #d4a017;
20
+ }
21
+ }
22
+ * { box-sizing: border-box; }
23
+ body { margin: 0; font: 15px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif;
24
+ background: var(--bg); color: var(--ink); }
25
+ header { position: sticky; top: 0; z-index: 1; padding: 12px 16px; border-bottom: 1px solid var(--line);
26
+ background: var(--panel); display: flex; gap: 16px; align-items: center; flex-wrap: wrap; }
27
+ header h1 { font-size: 16px; margin: 0; }
28
+ .muted { color: var(--muted); }
29
+ #status { margin-left: auto; font-size: 13px; }
30
+ #status.off { color: var(--danger); }
31
+ main { padding: 16px; max-width: 900px; margin: 0 auto; }
32
+ article { background: var(--panel); border: 1px solid var(--line); border-left: 6px solid var(--line);
33
+ border-radius: 8px; padding: 12px 14px; margin-bottom: 12px; }
34
+ article.open { border-left-color: #d4a017; }
35
+ article.answered { border-left-color: var(--accent); }
36
+ article.processed { border-left-color: #2e8b57; }
37
+ article h2 { font-size: 14px; margin: 0 0 6px; display: flex; gap: 10px; align-items: baseline; }
38
+ article h2 .id { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
39
+ .badge { font-size: 11px; text-transform: uppercase; letter-spacing: .05em; padding: 2px 8px;
40
+ border-radius: 999px; background: var(--open); }
41
+ .answered .badge { background: var(--answered); }
42
+ .processed .badge { background: var(--processed); }
43
+ .badge.unsent { background: var(--danger); color: #fff; }
44
+ .experimental { font-size: 11px; text-transform: uppercase; letter-spacing: .06em;
45
+ padding: 2px 8px; border-radius: 999px; border: 1px solid var(--warn-mark);
46
+ color: var(--warn-ink); background: var(--warn-bg); white-space: nowrap; }
47
+ #experimental-note { margin: 0 0 14px; padding: 10px 12px; font-size: 13px;
48
+ border: 1px solid var(--warn-line); border-left: 4px solid var(--warn-mark);
49
+ border-radius: 6px; background: var(--warn-bg); color: var(--warn-ink); }
50
+ .text { white-space: pre-wrap; margin: 0 0 8px; }
51
+ dl { margin: 0 0 8px; font-size: 13px; }
52
+ dl dt { color: var(--muted); }
53
+ dl dd { margin: 0 0 6px; white-space: pre-wrap; }
54
+ fieldset { border: 1px solid var(--line); border-radius: 6px; margin: 0 0 8px; padding: 6px 10px; }
55
+ legend { font-size: 13px; color: var(--muted); }
56
+ label.choice { display: inline-block; margin-right: 16px; padding: 3px 0; cursor: pointer; }
57
+ label.choice .desc { color: var(--muted); font-size: 13px; }
58
+ textarea { width: 100%; min-height: 56px; padding: 8px; border: 1px solid var(--line); border-radius: 6px;
59
+ background: var(--bg); color: var(--ink); font: inherit; resize: vertical; }
60
+ .actions { display: flex; gap: 8px; align-items: center; margin-top: 8px; flex-wrap: wrap; }
61
+ button { font: inherit; padding: 6px 14px; border-radius: 6px; border: 1px solid var(--line);
62
+ background: var(--panel); color: var(--ink); cursor: pointer; }
63
+ button.primary { background: var(--accent); color: var(--accent-ink); border-color: var(--accent); }
64
+ button:disabled { opacity: .5; cursor: default; }
65
+ .given { font-size: 13px; color: var(--muted); white-space: pre-wrap; }
66
+ .md p { margin: 0 0 8px; } .md ul, .md ol { margin: 0 0 8px; padding-left: 22px; }
67
+ .md h3, .md h4, .md h5, .md h6 { margin: 8px 0 4px; font-size: 14px; }
68
+ .md pre { background: var(--bg); border: 1px solid var(--line); border-radius: 6px; padding: 8px; overflow-x: auto; font-size: 13px; }
69
+ .md code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 13px; }
70
+ .text.md { white-space: normal; }
71
+ .custom { display: flex; gap: 12px; flex-wrap: wrap; font-size: 13px; color: var(--muted); margin: 0 0 8px; }
72
+ .error { color: var(--danger); font-size: 13px; }
73
+ #note { font-size: 13px; color: var(--muted); margin: 0 0 12px; }
74
+ </style>
75
+ </head>
76
+ <body>
77
+ <header>
78
+ <h1 id="title">ww</h1>
79
+ <span class="experimental" title="The operator page is still taking shape; its behaviour may change.">experimental</span>
80
+ <span class="muted" id="progress"></span>
81
+ <button type="button" id="pause">I'm done for now</button>
82
+ <span id="status" class="off">connecting…</span>
83
+ </header>
84
+ <main>
85
+ <p id="experimental-note">
86
+ The operator page is <strong>experimental</strong>. It works, but its
87
+ layout, keyboard behaviour, and the way answers are applied may change
88
+ between updates. Your answers are recorded by ww as you give them, so
89
+ nothing is lost if you close this tab — but if the page misbehaves, you
90
+ can always answer through <code>ww interact</code> in the terminal
91
+ instead.
92
+ </p>
93
+ <p id="note"></p>
94
+ <div id="items"></div>
95
+ </main>
96
+ <script>
97
+ (function () {
98
+ const $ = (id) => document.getElementById(id);
99
+ const key = "ww-operator-page:" + location.port;
100
+ let connected = false;
101
+ let lastSignature = null;
102
+ let latest = null;
103
+ // Drafts and answers that could not be sent survive in this tab's storage.
104
+ const local = load();
105
+
106
+ function load() {
107
+ try { return JSON.parse(localStorage.getItem(key) || "{}"); } catch (_) { return {}; }
108
+ }
109
+ function save() {
110
+ try { localStorage.setItem(key, JSON.stringify(local)); } catch (_) {}
111
+ }
112
+ // A draft is the comment being typed; a pick is sent the moment it is
113
+ // made, so what is shown as picked always comes from ww or from the
114
+ // queue of answers that could not be sent.
115
+ function draft(itemId) {
116
+ local.drafts = local.drafts || {};
117
+ if (!local.drafts[itemId]) local.drafts[itemId] = { comment: "" };
118
+ return local.drafts[itemId];
119
+ }
120
+ function queued(itemId) { return (local.queue || {})[itemId]; }
121
+ function bindRun(state) {
122
+ if (local.run_id !== state.run_id) {
123
+ // Another run of the task: nothing typed for the old one applies.
124
+ for (const key of Object.keys(local)) delete local[key];
125
+ local.run_id = state.run_id;
126
+ save();
127
+ }
128
+ }
129
+
130
+ function esc(text) {
131
+ return String(text).replace(/[&<>"']/g, (c) => ({
132
+ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;"
133
+ })[c]);
134
+ }
135
+
136
+ // A small Markdown renderer: headings, lists, code, emphasis, links.
137
+ function inline(text) {
138
+ return esc(text)
139
+ .replace(/`([^`]+)`/g, "<code>$1</code>")
140
+ .replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
141
+ .replace(/(^|[^*])\*([^*\n]+)\*/g, "$1<em>$2</em>")
142
+ .replace(/\[([^\]]+)\]\((https?:[^)\s]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
143
+ }
144
+ function markdown(text) {
145
+ const lines = String(text || "").replace(/\r\n?/g, "\n").split("\n");
146
+ const out = [];
147
+ let list = null, para = [], code = null;
148
+ const flush = () => {
149
+ if (para.length) { out.push(`<p>${para.map(inline).join("<br>")}</p>`); para = []; }
150
+ if (list) { out.push(`</${list}>`); list = null; }
151
+ };
152
+ for (const raw of lines) {
153
+ if (code !== null) {
154
+ if (/^```/.test(raw)) { out.push(`<pre><code>${esc(code.join("\n"))}</code></pre>`); code = null; }
155
+ else code.push(raw);
156
+ continue;
157
+ }
158
+ const line = raw.replace(/\s+$/, "");
159
+ let m;
160
+ if (/^```/.test(line)) { flush(); code = []; }
161
+ else if ((m = /^(#{1,6})\s+(.*)$/.exec(line))) { flush(); const n = Math.min(m[1].length + 2, 6); out.push(`<h${n}>${inline(m[2])}</h${n}>`); }
162
+ else if ((m = /^\s*[-*]\s+(.*)$/.exec(line))) { if (para.length) flush(); if (list !== "ul") { if (list) out.push(`</${list}>`); out.push("<ul>"); list = "ul"; } out.push(`<li>${inline(m[1])}</li>`); }
163
+ else if ((m = /^\s*\d+[.)]\s+(.*)$/.exec(line))) { if (para.length) flush(); if (list !== "ol") { if (list) out.push(`</${list}>`); out.push("<ol>"); list = "ol"; } out.push(`<li>${inline(m[1])}</li>`); }
164
+ else if (line === "") flush();
165
+ else { if (list) { out.push(`</${list}>`); list = null; } para.push(line); }
166
+ }
167
+ if (code !== null) out.push(`<pre><code>${esc(code.join("\n"))}</code></pre>`);
168
+ flush();
169
+ return out.join("");
170
+ }
171
+
172
+ function card(item, choices) {
173
+ const unsent = queued(item.id);
174
+ const badge = unsent ? `<span class="badge unsent">not sent</span>`
175
+ : `<span class="badge">${{ open: "open", answered: "answered", processed: "applied" }[item.status]}</span>`;
176
+ const fields = [["Analysis", item.processed_item], ["Proposed solution", item.proposed_solution],
177
+ ["Actual solution", item.actual_solution]].filter(([, v]) => v);
178
+ const custom = Object.entries(item.fields || {});
179
+ let body = `<h2><span class="id">${esc(item.id)}</span>${badge}</h2>` +
180
+ `<div class="text md">${markdown(item.item)}</div>` +
181
+ (fields.length ? `<dl>${fields.map(([k, v]) => `<dt>${esc(k)}</dt><dd class="md">${markdown(v)}</dd>`).join("")}</dl>` : "") +
182
+ (custom.length ? `<div class="custom">${custom.map(([k, v]) => `<span><code>${esc(k)}</code> ${esc(v)}</span>`).join("")}</div>` : "");
183
+ if (item.status === "processed") {
184
+ body += `<div class="given">Answer: ${esc(item.answer || "(comment)")}` +
185
+ (item.answer_comment ? `\n${esc(item.answer_comment)}` : "") + `</div>`;
186
+ return body;
187
+ }
188
+ const d = draft(item.id);
189
+ const picked = unsent ? unsent.choice : item.answer;
190
+ const comment = unsent ? unsent.comment : (d.comment || item.answer_comment || "");
191
+ d.comment = comment;
192
+ if (choices.length) {
193
+ body += `<fieldset><legend>Answer</legend>` + choices.map((choice, i) =>
194
+ `<label class="choice"><input type="radio" name="choice-${esc(item.id)}" value="${esc(choice.label)}"` +
195
+ `${choice.label === picked ? " checked" : ""}> <strong>${i + 1}. ${esc(choice.label)}</strong>` +
196
+ (choice.description ? ` <span class="desc">— ${esc(choice.description)}</span>` : "") + `</label>`
197
+ ).join("") + `</fieldset>`;
198
+ }
199
+ body += `<textarea data-comment="${esc(item.id)}" placeholder="Comment${choices.length ? " (optional; saved as you type)" : " (saved as you type)"}">${esc(comment)}</textarea>` +
200
+ `<div class="actions">` +
201
+ (item.answered_at ? `<span class="given">recorded ${esc(item.answered_at)}</span>` : `<span class="given">not answered yet</span>`) +
202
+ (unsent ? `<span class="error">Not sent yet: the agent was not waiting. It is sent when it waits again; closing this tab loses it.</span>` : "") +
203
+ `<span class="error" data-error="${esc(item.id)}"></span></div>`;
204
+ return body;
205
+ }
206
+
207
+ function render(state) {
208
+ latest = state;
209
+ $("title").textContent = `${state.task_id} · ${state.workflow}` + (state.stage ? ` · ${state.stage}` : "");
210
+ $("progress").textContent = `${state.answered} of ${state.total} answered`;
211
+ $("note").textContent = state.paused
212
+ ? "You said you were done for now; the agent has stopped. Answering an item brings it back."
213
+ : connected ? "Answer the items in any order. Every answer is recorded as soon as you give it."
214
+ : "The agent is not waiting right now.";
215
+ const holder = $("items");
216
+ holder.innerHTML = state.items.map((item) =>
217
+ `<article class="${esc(item.status)}" data-item="${esc(item.id)}">${card(item, state.choices)}</article>`
218
+ ).join("");
219
+ holder.querySelectorAll("textarea[data-comment]").forEach((area) => {
220
+ const itemId = area.dataset.comment;
221
+ area.addEventListener("input", () => {
222
+ draft(itemId).comment = area.value; save();
223
+ clearTimeout(timers[itemId]);
224
+ timers[itemId] = setTimeout(() => answer(itemId, true), 1200);
225
+ });
226
+ area.addEventListener("blur", () => { clearTimeout(timers[itemId]); answer(itemId, true); });
227
+ });
228
+ holder.querySelectorAll("input[type=radio]").forEach((radio) => {
229
+ radio.addEventListener("change", () => answer(radio.name.slice(7), false, radio.value));
230
+ });
231
+ save();
232
+ setEnabled();
233
+ }
234
+
235
+ // Keep what the operator is typing: refresh only the marks around it.
236
+ function patchStatus(state) {
237
+ latest = state;
238
+ $("progress").textContent = `${state.answered} of ${state.total} answered`;
239
+ for (const item of state.items) {
240
+ const article = document.querySelector(`article[data-item="${CSS.escape(item.id)}"]`);
241
+ if (!article) continue;
242
+ article.className = item.status;
243
+ const badge = article.querySelector(".badge:not(.unsent)");
244
+ if (badge) badge.textContent = { open: "open", answered: "answered", processed: "applied" }[item.status];
245
+ const given = article.querySelector(".actions .given");
246
+ if (given) given.textContent = item.answered_at ? `recorded ${item.answered_at}` : "not answered yet";
247
+ }
248
+ }
249
+
250
+ function setEnabled() {
251
+ document.querySelectorAll("button").forEach((button) => { button.disabled = !connected; });
252
+ }
253
+
254
+ async function post(path, payload) {
255
+ const response = await fetch(path, { method: "POST", headers: { "Content-Type": "application/json" },
256
+ body: JSON.stringify(payload) });
257
+ const body = await response.json().catch(() => ({}));
258
+ if (!response.ok) throw new Error(body.error || response.statusText);
259
+ }
260
+
261
+ async function send(itemId, d) {
262
+ await post("/act", { action: "answer", item_id: itemId, choice: d.choice, comment: d.comment.trim() });
263
+ delete (local.queue || {})[itemId];
264
+ save();
265
+ }
266
+
267
+ const timers = {};
268
+ const sent = {};
269
+
270
+ function pickedNow(itemId) {
271
+ const checked = document.querySelector(`input[name="choice-${CSS.escape(itemId)}"]:checked`);
272
+ return checked ? checked.value : null;
273
+ }
274
+
275
+ async function answer(itemId, fromComment, choice) {
276
+ const d = { choice: choice || pickedNow(itemId), comment: draft(itemId).comment || "" };
277
+ const error = document.querySelector(`[data-error="${CSS.escape(itemId)}"]`);
278
+ if (error) error.textContent = "";
279
+ const hasChoices = latest && latest.choices.length;
280
+ // A comment alone cannot answer a stage with choices; wait for the pick.
281
+ if (fromComment && hasChoices && !d.choice) return;
282
+ if (!hasChoices && !d.comment.trim()) return;
283
+ const signature = JSON.stringify([d.choice, d.comment.trim()]);
284
+ if (sent[itemId] === signature) return;
285
+ try {
286
+ await send(itemId, d);
287
+ sent[itemId] = signature;
288
+ } catch (failure) {
289
+ if (failure instanceof TypeError) {
290
+ // The server is gone: keep the answer to send when a wait is back.
291
+ local.queue = local.queue || {};
292
+ local.queue[itemId] = { choice: d.choice, comment: d.comment };
293
+ save();
294
+ } else if (error) {
295
+ error.textContent = failure.message;
296
+ }
297
+ }
298
+ lastSignature = null;
299
+ refresh();
300
+ }
301
+
302
+ async function flushQueue() {
303
+ for (const [itemId, d] of Object.entries(local.queue || {})) {
304
+ try { await send(itemId, d); } catch (failure) {
305
+ if (!(failure instanceof TypeError)) { delete local.queue[itemId]; save(); }
306
+ return;
307
+ }
308
+ }
309
+ }
310
+
311
+ $("pause").addEventListener("click", async () => {
312
+ try { await post("/act", { action: "pause" }); lastSignature = null; } catch (failure) { alert(failure.message); }
313
+ refresh();
314
+ });
315
+
316
+ window.addEventListener("pagehide", () => {
317
+ if (connected && navigator.sendBeacon) navigator.sendBeacon("/closing", "{}");
318
+ });
319
+
320
+ async function refresh() {
321
+ try {
322
+ const response = await fetch("/state", { cache: "no-store" });
323
+ if (!response.ok) throw new Error(response.statusText);
324
+ const state = await response.json();
325
+ const wasConnected = connected;
326
+ connected = true;
327
+ $("status").textContent = "agent is waiting for your answers";
328
+ $("status").classList.remove("off");
329
+ if (!wasConnected && Object.keys(local.queue || {}).length) { await flushQueue(); lastSignature = null; }
330
+ bindRun(state);
331
+ const signature = JSON.stringify(state);
332
+ const typing = document.activeElement && document.activeElement.tagName === "TEXTAREA";
333
+ if (signature !== lastSignature || !wasConnected) {
334
+ lastSignature = signature;
335
+ if (typing) patchStatus(state); else render(state);
336
+ }
337
+ } catch (failure) {
338
+ connected = false;
339
+ $("status").textContent = "waiting for the agent…";
340
+ $("status").classList.add("off");
341
+ setEnabled();
342
+ if (latest) $("note").textContent = "The agent is not waiting right now. Answers you give now are kept in this tab and sent when it waits again.";
343
+ }
344
+ }
345
+
346
+ refresh();
347
+ setInterval(refresh, 1000);
348
+ })();
349
+ </script>
350
+ </body>
351
+ </html>