@kontourai/flow-agents 0.1.1 → 0.2.0

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 (97) hide show
  1. package/.github/dependabot.yml +23 -0
  2. package/.github/workflows/publish-npm.yml +1 -1
  3. package/.github/workflows/release-please.yml +31 -0
  4. package/.github/workflows/runtime-compat.yml +118 -0
  5. package/CHANGELOG.md +38 -0
  6. package/CONTRIBUTING.md +4 -0
  7. package/README.md +58 -19
  8. package/build/src/cli/init.js +215 -5
  9. package/build/src/cli/utterance-check.js +236 -0
  10. package/build/src/cli.js +3 -0
  11. package/build/src/tools/build-universal-bundles.js +268 -0
  12. package/build/src/tools/filter-installed-packs.js +3 -0
  13. package/build/src/tools/validate-source-tree.js +6 -1
  14. package/context/scripts/telemetry/lib/config.sh +5 -1
  15. package/context/settings/flow-agents-settings.json +7 -0
  16. package/docs/agent-system-guidebook.md +4 -5
  17. package/docs/context-map.md +1 -0
  18. package/docs/index.md +46 -6
  19. package/docs/integrations/conformance.md +246 -0
  20. package/docs/integrations/framework-adapter.md +275 -0
  21. package/docs/integrations/harness-install.md +213 -0
  22. package/docs/integrations/index.md +54 -0
  23. package/docs/north-star.md +3 -3
  24. package/docs/repository-structure.md +1 -1
  25. package/docs/skills-map.md +10 -4
  26. package/docs/spec/runtime-hook-surface.md +472 -0
  27. package/docs/survey-utterance-check.md +308 -0
  28. package/docs/vision.md +45 -0
  29. package/docs/workflow-usage-guide.md +1 -1
  30. package/evals/acceptance/run.sh +4 -2
  31. package/evals/acceptance/test_opencode_harness.sh +121 -0
  32. package/evals/acceptance/test_pi_harness.sh +98 -0
  33. package/evals/integration/test_bundle_install.sh +226 -1
  34. package/evals/integration/test_bundle_lifecycle.sh +641 -0
  35. package/evals/integration/test_utterance_check.sh +518 -0
  36. package/evals/run.sh +2 -0
  37. package/evals/static/test_universal_bundles.sh +137 -2
  38. package/integrations/strands/README.md +256 -0
  39. package/integrations/strands/example.py +74 -0
  40. package/integrations/strands/flow_agents_strands/__init__.py +27 -0
  41. package/integrations/strands/flow_agents_strands/hooks.py +194 -0
  42. package/integrations/strands/flow_agents_strands/policy.py +348 -0
  43. package/integrations/strands/flow_agents_strands/steering.py +172 -0
  44. package/integrations/strands/flow_agents_strands/telemetry.py +238 -0
  45. package/integrations/strands/pyproject.toml +38 -0
  46. package/integrations/strands/tests/__init__.py +0 -0
  47. package/integrations/strands/tests/test_hooks.py +304 -0
  48. package/integrations/strands/tests/test_policy.py +315 -0
  49. package/integrations/strands/tests/test_telemetry.py +184 -0
  50. package/integrations/strands-ts/README.md +224 -0
  51. package/integrations/strands-ts/bin/conformance-shim.mjs +257 -0
  52. package/integrations/strands-ts/package.json +53 -0
  53. package/integrations/strands-ts/src/hooks.ts +208 -0
  54. package/integrations/strands-ts/src/index.ts +22 -0
  55. package/integrations/strands-ts/src/policy.ts +345 -0
  56. package/integrations/strands-ts/src/telemetry.ts +251 -0
  57. package/integrations/strands-ts/test/test-policy.ts +322 -0
  58. package/integrations/strands-ts/test/test-telemetry.ts +226 -0
  59. package/integrations/strands-ts/tsconfig.json +20 -0
  60. package/package.json +7 -2
  61. package/packaging/conformance/README.md +142 -0
  62. package/packaging/conformance/fixtures/config-protection--allow-no-path.json +18 -0
  63. package/packaging/conformance/fixtures/config-protection--allow-safe-file.json +20 -0
  64. package/packaging/conformance/fixtures/config-protection--block-biome.json +20 -0
  65. package/packaging/conformance/fixtures/config-protection--block-eslintrc.json +20 -0
  66. package/packaging/conformance/fixtures/quality-gate--allow-no-path.json +17 -0
  67. package/packaging/conformance/fixtures/quality-gate--allow-nonexistent-file.json +19 -0
  68. package/packaging/conformance/fixtures/stop-goal-fit--allow-clean-cwd.json +17 -0
  69. package/packaging/conformance/fixtures/stop-goal-fit--block-strict-mode.json +23 -0
  70. package/packaging/conformance/fixtures/stop-goal-fit--warn-active-delivery.json +21 -0
  71. package/packaging/conformance/fixtures/workflow-steering--allow-no-state.json +16 -0
  72. package/packaging/conformance/fixtures/workflow-steering--inject-active-state.json +29 -0
  73. package/packaging/conformance/fixtures/workflow-steering--inject-subagent-steering.json +25 -0
  74. package/packaging/conformance/package.json +4 -0
  75. package/packaging/conformance/run-conformance.js +322 -0
  76. package/packaging/manifest.json +59 -0
  77. package/schemas/flow-agents-settings.schema.json +48 -0
  78. package/scripts/README.md +5 -0
  79. package/scripts/dogfood.js +16 -0
  80. package/scripts/hooks/opencode-hook-adapter.js +123 -0
  81. package/scripts/hooks/opencode-telemetry-hook.js +101 -0
  82. package/scripts/hooks/pi-hook-adapter.js +123 -0
  83. package/scripts/hooks/pi-telemetry-hook.js +105 -0
  84. package/scripts/hooks/run-hook.js +8 -0
  85. package/scripts/hooks/utterance-check.js +327 -0
  86. package/scripts/telemetry/lib/config.sh +5 -1
  87. package/skills/idea-to-backlog/SKILL.md +1 -1
  88. package/src/cli/init.ts +219 -6
  89. package/src/cli/utterance-check.ts +324 -0
  90. package/src/cli.ts +3 -0
  91. package/src/tools/build-universal-bundles.ts +266 -0
  92. package/src/tools/filter-installed-packs.ts +3 -0
  93. package/src/tools/validate-source-tree.ts +6 -1
  94. package/build/src/cli/docs-preview.js +0 -39
  95. package/build/src/cli/export-bookmarks.js +0 -38
  96. package/build/src/cli/import-bookmarks.js +0 -50
  97. package/build/src/cli/instinct-cli.js +0 -93
@@ -0,0 +1,172 @@
1
+ """
2
+ steering.py — Workflow-steering context loader.
3
+
4
+ Mirrors the logic in scripts/hooks/workflow-steering.js for reading
5
+ .flow-agents/*/state.json and producing a steering text blob.
6
+
7
+ Unlike the JS version this module does NOT inject into a prompt directly —
8
+ Strands' BeforeInvocationEvent does not expose a mutable system_prompt at
9
+ callback time. Instead, the caller is expected to call
10
+ FlowAgentsHooks.steering_context() at Agent construction and prepend the
11
+ result to the system prompt. See README.md § Limitations for details.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import os
18
+ from pathlib import Path
19
+ from typing import Any, Dict, List, Optional
20
+
21
+ # Active statuses that warrant surfacing steering context (mirrors workflow-steering.js)
22
+ _ACTIVE_STATUSES = frozenset(
23
+ [
24
+ "new",
25
+ "planning",
26
+ "planned",
27
+ "in_progress",
28
+ "blocked",
29
+ "verifying",
30
+ "verified",
31
+ "needs_decision",
32
+ "not_verified",
33
+ "failed",
34
+ "delivered",
35
+ ]
36
+ )
37
+
38
+ _AMBIENT_STATUSES = frozenset(["blocked", "failed", "needs_decision", "not_verified"])
39
+
40
+
41
+ def _find_repo_root(start: Optional[str] = None) -> Path:
42
+ """Walk up from start until .git or AGENTS.md is found."""
43
+ current = Path(start).resolve() if start else Path.cwd()
44
+ for _ in range(40):
45
+ if (current / ".git").exists() or (current / "AGENTS.md").exists():
46
+ return current
47
+ parent = current.parent
48
+ if parent == current:
49
+ break
50
+ current = parent
51
+ return Path(start).resolve() if start else Path.cwd()
52
+
53
+
54
+ def _walk_state_files(flow_agents_dir: Path) -> List[Path]:
55
+ """Recursively find all state.json files, skipping archive/ dirs."""
56
+ results: List[Path] = []
57
+ if not flow_agents_dir.exists():
58
+ return results
59
+ for entry in flow_agents_dir.rglob("state.json"):
60
+ if "archive" in entry.parts:
61
+ continue
62
+ results.append(entry)
63
+ return results
64
+
65
+
66
+ def _read_json(path: Path) -> Optional[Dict[str, Any]]:
67
+ try:
68
+ return json.loads(path.read_text(encoding="utf-8"))
69
+ except Exception:
70
+ return None
71
+
72
+
73
+ def _safe_text(value: Any, max_length: int = 240) -> str:
74
+ text = " ".join(str(value or "").split()).strip()
75
+ if len(text) <= max_length:
76
+ return text
77
+ return text[: max_length - 3] + "..."
78
+
79
+
80
+ class SteeringContext:
81
+ """
82
+ Loads Flow Agents workflow-steering context from .flow-agents/ state files.
83
+ """
84
+
85
+ def __init__(self, workspace: Optional[str] = None) -> None:
86
+ self._root = _find_repo_root(workspace)
87
+ self._flow_agents_dir = self._root / ".flow-agents"
88
+
89
+ def load(self) -> str:
90
+ """
91
+ Return a steering text string (possibly empty) for the current
92
+ workflow state. Mirrors the stateSteering() + contextMapSteering()
93
+ output from workflow-steering.js.
94
+ """
95
+ parts: List[str] = []
96
+
97
+ state_hint = self._state_steering()
98
+ if state_hint:
99
+ parts.append(state_hint)
100
+
101
+ ctx_hint = self._context_map_steering()
102
+ if ctx_hint:
103
+ parts.append(ctx_hint)
104
+
105
+ if not parts:
106
+ return ""
107
+
108
+ return "\n\n---\n" + "\n".join(parts) + "\n---"
109
+
110
+ def _latest_active_state(self) -> Optional[Dict[str, Any]]:
111
+ candidates = []
112
+ for path in _walk_state_files(self._flow_agents_dir):
113
+ payload = _read_json(path)
114
+ if not payload:
115
+ continue
116
+ if payload.get("status") not in _ACTIVE_STATUSES:
117
+ continue
118
+ try:
119
+ mtime = path.stat().st_mtime_ns
120
+ except OSError:
121
+ continue
122
+ candidates.append((mtime, path, payload))
123
+
124
+ if not candidates:
125
+ return None
126
+ candidates.sort(key=lambda t: t[0], reverse=True)
127
+ _, path, payload = candidates[0]
128
+ return {"file": str(path), "payload": payload}
129
+
130
+ def _state_steering(self) -> str:
131
+ current = self._latest_active_state()
132
+ if not current:
133
+ return ""
134
+ state = current["payload"]
135
+ next_action = state.get("next_action") or {}
136
+
137
+ if next_action.get("status") == "done":
138
+ return ""
139
+ if state.get("status") in ("archived", "accepted"):
140
+ return ""
141
+
142
+ task_slug = state.get("task_slug") or Path(current["file"]).parent.name
143
+ parts = [
144
+ f"STATE: {task_slug} is status:{state.get('status')} phase:{state.get('phase')}."
145
+ ]
146
+ if next_action.get("summary"):
147
+ parts.append(
148
+ f"Recorded next_action.summary: \"{_safe_text(next_action['summary'])}\""
149
+ )
150
+ if next_action.get("target_phase"):
151
+ parts.append(f"Target phase: {_safe_text(next_action['target_phase'], 80)}.")
152
+ if (
153
+ next_action.get("status") == "needs_user"
154
+ or state.get("status") in ("needs_decision", "not_verified")
155
+ ):
156
+ parts.append(
157
+ "Do not deliver as complete until the user decision or accepted gap is recorded."
158
+ )
159
+ if state.get("status") == "failed":
160
+ parts.append("Route back through execution, then re-review and re-verify.")
161
+
162
+ return " ".join(parts)
163
+
164
+ def _context_map_steering(self) -> str:
165
+ map_path = self._root / "docs" / "context-map.md"
166
+ if not map_path.exists():
167
+ return ""
168
+ return (
169
+ "CONTEXT MAP: use docs/context-map.md before broad repo rediscovery. "
170
+ "If structure, commands, schemas, skills, agents, or packs changed, "
171
+ "run `npm run context-map -- --check`."
172
+ )
@@ -0,0 +1,238 @@
1
+ """
2
+ telemetry.py — Canonical Flow Agents telemetry event builder and JSONL sink.
3
+
4
+ Event taxonomy mirrors the JS telemetry hooks exactly:
5
+
6
+ claude-telemetry-hook.js → canonicalEvent() mapping:
7
+ SessionStart → agentSpawn
8
+ UserPromptSubmit → userPromptSubmit
9
+ PreToolUse → preToolUse
10
+ PostToolUse → postToolUse
11
+ PostToolUseFailure → postToolUse
12
+ Stop / SessionEnd → stop
13
+ SubagentStart → subagentStart
14
+ SubagentStop → subagentStop
15
+
16
+ telemetry.sh → schema_event_type():
17
+ agentSpawn / SessionStart → session.start
18
+ stop / Stop / SessionEnd → session.end
19
+ userPromptSubmit / UserPromptSubmit → turn.user
20
+ preToolUse / PreToolUse → tool.invoke
21
+ postToolUse / PostToolUse → tool.result
22
+
23
+ Strands hook events are mapped to the same canonical names so the emitted
24
+ JSONL records are structurally identical to those produced by the Claude Code
25
+ and Codex telemetry hooks.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import json
31
+ import os
32
+ import time
33
+ import uuid
34
+ from pathlib import Path
35
+ from typing import Any, Dict, Optional
36
+
37
+ # ---------------------------------------------------------------------------
38
+ # Strands → canonical event-name mapping
39
+ # (module-level dict so it is inspectable / documented)
40
+ # ---------------------------------------------------------------------------
41
+
42
+ STRANDS_TO_CANONICAL: Dict[str, str] = {
43
+ # Strands event class name → canonical Flow Agents event name
44
+ "AgentInitializedEvent": "agentSpawn",
45
+ "BeforeInvocationEvent": "userPromptSubmit",
46
+ "AfterInvocationEvent": "stop",
47
+ "BeforeToolCallEvent": "preToolUse",
48
+ "AfterToolCallEvent": "postToolUse",
49
+ "AfterModelCallEvent": "postToolUse", # closest analogue; no tool name
50
+ "MessageAddedEvent": "userPromptSubmit",
51
+ }
52
+
53
+ # Canonical → schema event type (mirrors telemetry.sh schema_event_type())
54
+ _CANONICAL_TO_SCHEMA: Dict[str, str] = {
55
+ "agentSpawn": "session.start",
56
+ "userPromptSubmit": "turn.user",
57
+ "preToolUse": "tool.invoke",
58
+ "permissionRequest": "tool.permission_request",
59
+ "postToolUse": "tool.result",
60
+ "stop": "session.end",
61
+ "subagentStart": "agent.delegate",
62
+ "subagentStop": "agent.delegate",
63
+ }
64
+
65
+
66
+ def _schema_event_type(canonical: str) -> str:
67
+ return _CANONICAL_TO_SCHEMA.get(canonical, "unknown")
68
+
69
+
70
+ # ---------------------------------------------------------------------------
71
+ # JSONL sink
72
+ # ---------------------------------------------------------------------------
73
+
74
+ class TelemetrySink:
75
+ """
76
+ Writes canonical Flow Agents telemetry events to a JSONL file.
77
+
78
+ Default path: <workspace>/.flow-agents/.telemetry/full.jsonl
79
+ This matches the local-files sink convention from config.sh:
80
+ TELEMETRY_CHANNEL_FULL_LOG_FILE = <data_dir>/full.jsonl
81
+ where data_dir defaults to <repo_root>/.telemetry/
82
+
83
+ For the Strands adapter we follow the harness convention of writing
84
+ inside .flow-agents/.telemetry/ to keep everything under one dot-dir.
85
+ """
86
+
87
+ DEFAULT_SUBDIR = Path(".flow-agents") / ".telemetry"
88
+ DEFAULT_FILENAME = "full.jsonl"
89
+ SCHEMA_VERSION = "0.3.0"
90
+
91
+ def __init__(
92
+ self,
93
+ sink_path: Optional[str] = None,
94
+ workspace: Optional[str] = None,
95
+ agent_name: str = "strands-agent",
96
+ runtime: str = "strands",
97
+ ) -> None:
98
+ self.agent_name = agent_name
99
+ self.runtime = runtime
100
+ self._session_id: Optional[str] = None
101
+
102
+ ws = Path(workspace) if workspace else Path.cwd()
103
+ if sink_path:
104
+ p = Path(sink_path)
105
+ # If given a directory, append default filename
106
+ if p.suffix == "":
107
+ self._log_file = p / self.DEFAULT_FILENAME
108
+ else:
109
+ self._log_file = p
110
+ else:
111
+ self._log_file = ws / self.DEFAULT_SUBDIR / self.DEFAULT_FILENAME
112
+
113
+ self._log_file.parent.mkdir(parents=True, exist_ok=True)
114
+
115
+ @property
116
+ def session_id(self) -> str:
117
+ if self._session_id is None:
118
+ self._session_id = str(uuid.uuid4())
119
+ return self._session_id
120
+
121
+ def _base_event(self, schema_event_type: str) -> Dict[str, Any]:
122
+ """Build the base event envelope matching telemetry.sh build_base_event()."""
123
+ return {
124
+ "schema_version": self.SCHEMA_VERSION,
125
+ "timestamp": str(int(time.time() * 1000)),
126
+ "session_id": self.session_id,
127
+ "event_id": str(uuid.uuid4()),
128
+ "event_type": schema_event_type,
129
+ "agent": {
130
+ "name": self.agent_name,
131
+ "runtime": self.runtime,
132
+ "version": "unknown",
133
+ },
134
+ }
135
+
136
+ def emit(
137
+ self,
138
+ canonical_event: str,
139
+ extra: Optional[Dict[str, Any]] = None,
140
+ ) -> Dict[str, Any]:
141
+ """
142
+ Build and write a canonical telemetry event.
143
+
144
+ Returns the emitted dict (useful for tests / callers that need the
145
+ event for further processing).
146
+ """
147
+ schema_type = _schema_event_type(canonical_event)
148
+ event = self._base_event(schema_type)
149
+
150
+ # Attach hook context stub (mirrors add_hook_context() in telemetry.sh)
151
+ event["hook"] = {
152
+ "event_name": canonical_event,
153
+ "runtime_session_id": "",
154
+ "turn_id": "",
155
+ "transcript_path": "",
156
+ "model": "",
157
+ "source": "strands",
158
+ "stop_hook_active": None,
159
+ "last_assistant_message": "",
160
+ "raw_input": None,
161
+ }
162
+
163
+ if extra:
164
+ event.update(extra)
165
+
166
+ try:
167
+ with self._log_file.open("a", encoding="utf-8") as fh:
168
+ fh.write(json.dumps(event) + "\n")
169
+ except OSError:
170
+ pass # fail-open: telemetry must never block agent work
171
+
172
+ return event
173
+
174
+ def emit_session_start(self, extra: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
175
+ return self.emit("agentSpawn", extra)
176
+
177
+ def emit_session_end(self, duration_s: float = 0.0) -> Dict[str, Any]:
178
+ return self.emit("stop", {"session": {"duration_s": duration_s}})
179
+
180
+ def emit_tool_invoke(
181
+ self,
182
+ tool_name: str,
183
+ tool_input: Optional[Dict[str, Any]] = None,
184
+ ) -> Dict[str, Any]:
185
+ return self.emit(
186
+ "preToolUse",
187
+ {
188
+ "tool": {
189
+ "name": tool_name,
190
+ "normalized_name": _normalize_tool_name(tool_name),
191
+ "input": tool_input,
192
+ }
193
+ },
194
+ )
195
+
196
+ def emit_tool_result(
197
+ self,
198
+ tool_name: str,
199
+ tool_output: Any = None,
200
+ ) -> Dict[str, Any]:
201
+ return self.emit(
202
+ "postToolUse",
203
+ {
204
+ "tool": {
205
+ "name": tool_name,
206
+ "normalized_name": _normalize_tool_name(tool_name),
207
+ "output": tool_output,
208
+ }
209
+ },
210
+ )
211
+
212
+ def emit_steering(self, steering_text: str) -> Dict[str, Any]:
213
+ """Emit a synthetic userPromptSubmit event carrying steering context."""
214
+ return self.emit(
215
+ "userPromptSubmit",
216
+ {"turn": {"prompt_text": "", "steering_context": steering_text}},
217
+ )
218
+
219
+
220
+ def _normalize_tool_name(name: str) -> str:
221
+ """
222
+ Mirror telemetry.sh normalize_tool_name() for the most common cases.
223
+ """
224
+ _MAP = {
225
+ "bash": "execute_bash",
226
+ "execute_bash": "execute_bash",
227
+ "shell": "execute_bash",
228
+ "edit": "fs_write",
229
+ "write": "fs_write",
230
+ "fs_write": "fs_write",
231
+ "apply_patch": "fs_write",
232
+ "read": "fs_read",
233
+ "fs_read": "fs_read",
234
+ "task": "use_subagent",
235
+ "agent": "use_subagent",
236
+ "use_subagent": "use_subagent",
237
+ }
238
+ return _MAP.get(name.lower(), name)
@@ -0,0 +1,38 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.backends.legacy:build"
4
+
5
+ [project]
6
+ name = "flow-agents-strands"
7
+ version = "0.0.1"
8
+ description = "Flow Agents framework adapter for AWS Strands Agents — telemetry, policy gates, and workflow steering via the Strands hook surface."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "MIT" }
12
+ keywords = ["flow-agents", "strands", "aws", "agents", "telemetry", "hooks"]
13
+ classifiers = [
14
+ "Development Status :: 2 - Pre-Alpha",
15
+ "Intended Audience :: Developers",
16
+ "Programming Language :: Python :: 3",
17
+ "Programming Language :: Python :: 3.9",
18
+ "Programming Language :: Python :: 3.10",
19
+ "Programming Language :: Python :: 3.11",
20
+ "Programming Language :: Python :: 3.12",
21
+ "Topic :: Software Development :: Libraries :: Python Modules",
22
+ ]
23
+ # No runtime dependencies — strands-agents is optional
24
+ dependencies = []
25
+
26
+ [project.optional-dependencies]
27
+ # Install the real Strands SDK when you want to wire into a live Agent
28
+ strands = [
29
+ "strands-agents>=0.1.0",
30
+ ]
31
+ # Development / test extras
32
+ dev = [
33
+ "strands-agents>=0.1.0",
34
+ ]
35
+
36
+ [tool.setuptools.packages.find]
37
+ where = ["."]
38
+ include = ["flow_agents_strands*"]
File without changes