@kontourai/flow-agents 0.1.2 → 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.
- package/.github/dependabot.yml +23 -0
- package/.github/workflows/release-please.yml +31 -0
- package/.github/workflows/runtime-compat.yml +118 -0
- package/CHANGELOG.md +23 -0
- package/CONTRIBUTING.md +4 -0
- package/README.md +53 -10
- package/build/src/cli/init.js +215 -5
- package/build/src/cli/utterance-check.js +65 -1
- package/build/src/tools/build-universal-bundles.js +268 -0
- package/build/src/tools/filter-installed-packs.js +3 -0
- package/build/src/tools/validate-source-tree.js +5 -1
- package/context/scripts/telemetry/lib/config.sh +5 -1
- package/context/settings/flow-agents-settings.json +7 -0
- package/docs/context-map.md +1 -0
- package/docs/index.md +45 -4
- package/docs/integrations/conformance.md +246 -0
- package/docs/integrations/framework-adapter.md +275 -0
- package/docs/integrations/harness-install.md +213 -0
- package/docs/integrations/index.md +54 -0
- package/docs/north-star.md +2 -2
- package/docs/spec/runtime-hook-surface.md +472 -0
- package/docs/survey-utterance-check.md +211 -94
- package/docs/vision.md +45 -0
- package/evals/acceptance/run.sh +4 -2
- package/evals/acceptance/test_opencode_harness.sh +121 -0
- package/evals/acceptance/test_pi_harness.sh +98 -0
- package/evals/integration/test_bundle_install.sh +226 -1
- package/evals/integration/test_bundle_lifecycle.sh +641 -0
- package/evals/integration/test_utterance_check.sh +291 -44
- package/evals/run.sh +2 -0
- package/evals/static/test_universal_bundles.sh +137 -2
- package/integrations/strands/README.md +256 -0
- package/integrations/strands/example.py +74 -0
- package/integrations/strands/flow_agents_strands/__init__.py +27 -0
- package/integrations/strands/flow_agents_strands/hooks.py +194 -0
- package/integrations/strands/flow_agents_strands/policy.py +348 -0
- package/integrations/strands/flow_agents_strands/steering.py +172 -0
- package/integrations/strands/flow_agents_strands/telemetry.py +238 -0
- package/integrations/strands/pyproject.toml +38 -0
- package/integrations/strands/tests/__init__.py +0 -0
- package/integrations/strands/tests/test_hooks.py +304 -0
- package/integrations/strands/tests/test_policy.py +315 -0
- package/integrations/strands/tests/test_telemetry.py +184 -0
- package/integrations/strands-ts/README.md +224 -0
- package/integrations/strands-ts/bin/conformance-shim.mjs +257 -0
- package/integrations/strands-ts/package.json +53 -0
- package/integrations/strands-ts/src/hooks.ts +208 -0
- package/integrations/strands-ts/src/index.ts +22 -0
- package/integrations/strands-ts/src/policy.ts +345 -0
- package/integrations/strands-ts/src/telemetry.ts +251 -0
- package/integrations/strands-ts/test/test-policy.ts +322 -0
- package/integrations/strands-ts/test/test-telemetry.ts +226 -0
- package/integrations/strands-ts/tsconfig.json +20 -0
- package/package.json +7 -2
- package/packaging/conformance/README.md +142 -0
- package/packaging/conformance/fixtures/config-protection--allow-no-path.json +18 -0
- package/packaging/conformance/fixtures/config-protection--allow-safe-file.json +20 -0
- package/packaging/conformance/fixtures/config-protection--block-biome.json +20 -0
- package/packaging/conformance/fixtures/config-protection--block-eslintrc.json +20 -0
- package/packaging/conformance/fixtures/quality-gate--allow-no-path.json +17 -0
- package/packaging/conformance/fixtures/quality-gate--allow-nonexistent-file.json +19 -0
- package/packaging/conformance/fixtures/stop-goal-fit--allow-clean-cwd.json +17 -0
- package/packaging/conformance/fixtures/stop-goal-fit--block-strict-mode.json +23 -0
- package/packaging/conformance/fixtures/stop-goal-fit--warn-active-delivery.json +21 -0
- package/packaging/conformance/fixtures/workflow-steering--allow-no-state.json +16 -0
- package/packaging/conformance/fixtures/workflow-steering--inject-active-state.json +29 -0
- package/packaging/conformance/fixtures/workflow-steering--inject-subagent-steering.json +25 -0
- package/packaging/conformance/package.json +4 -0
- package/packaging/conformance/run-conformance.js +322 -0
- package/packaging/manifest.json +59 -0
- package/schemas/flow-agents-settings.schema.json +48 -0
- package/scripts/README.md +4 -0
- package/scripts/dogfood.js +16 -0
- package/scripts/hooks/opencode-hook-adapter.js +123 -0
- package/scripts/hooks/opencode-telemetry-hook.js +101 -0
- package/scripts/hooks/pi-hook-adapter.js +123 -0
- package/scripts/hooks/pi-telemetry-hook.js +105 -0
- package/scripts/hooks/run-hook.js +8 -0
- package/scripts/hooks/utterance-check.js +124 -22
- package/scripts/telemetry/lib/config.sh +5 -1
- package/src/cli/init.ts +219 -6
- package/src/cli/utterance-check.ts +71 -1
- package/src/tools/build-universal-bundles.ts +266 -0
- package/src/tools/filter-installed-packs.ts +3 -0
- package/src/tools/validate-source-tree.ts +5 -1
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
"""
|
|
2
|
+
policy.py — Policy gates for BeforeToolCallEvent.
|
|
3
|
+
|
|
4
|
+
Primary binding: subprocess to the canonical Node.js engine
|
|
5
|
+
(scripts/hooks/run-hook.js → config-protection.js) for the authoritative
|
|
6
|
+
policy decision. The tool-name pre-filter (write-like tools only) is applied
|
|
7
|
+
in Python before calling the engine, since the engine itself does not filter by
|
|
8
|
+
tool name — it blocks on the file basename alone.
|
|
9
|
+
|
|
10
|
+
Fallback: if Node.js is absent or the engine script cannot be located, the gate
|
|
11
|
+
degrades to the pure-Python implementation of the same logic and emits a
|
|
12
|
+
one-time RuntimeWarning. See README.md §Limitations.
|
|
13
|
+
|
|
14
|
+
Engine contract (contract_version "1.0"):
|
|
15
|
+
- Payload: JSON on stdin with hook_event_name, tool_name, tool_input fields.
|
|
16
|
+
- Exit code 0 = allow, exit code 2 = block, other = error (fail-open).
|
|
17
|
+
- Stderr carries the block reason when exit code is 2.
|
|
18
|
+
- See docs/spec/runtime-hook-surface.md §8 for the full contract.
|
|
19
|
+
|
|
20
|
+
Custom protected_files: when the caller passes a non-default ``protected_files``
|
|
21
|
+
frozenset, the engine subprocess is bypassed and the Python evaluation is used
|
|
22
|
+
directly (the subprocess cannot receive a runtime-custom set).
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import json
|
|
28
|
+
import os
|
|
29
|
+
import shutil
|
|
30
|
+
import subprocess
|
|
31
|
+
import warnings
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
from typing import Optional
|
|
34
|
+
|
|
35
|
+
# ---------------------------------------------------------------------------
|
|
36
|
+
# Locate the canonical engine — supports both installed-package and repo layouts.
|
|
37
|
+
# ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
def _find_engine_paths() -> tuple[Optional[str], Optional[str]]:
|
|
40
|
+
"""
|
|
41
|
+
Return (node_executable, run_hook_path) or (None, None) if unavailable.
|
|
42
|
+
|
|
43
|
+
Search order for run-hook.js:
|
|
44
|
+
1. Env var FLOW_AGENTS_ENGINE_PATH (explicit override).
|
|
45
|
+
2. Relative to this file: ../../../../scripts/hooks/run-hook.js
|
|
46
|
+
(works when running from the source repo checkout).
|
|
47
|
+
3. Installed via npm: walk up from CWD looking for
|
|
48
|
+
node_modules/@kontourai/flow-agents/scripts/hooks/run-hook.js
|
|
49
|
+
"""
|
|
50
|
+
node = shutil.which("node")
|
|
51
|
+
if not node:
|
|
52
|
+
return None, None
|
|
53
|
+
|
|
54
|
+
# 1. Explicit override
|
|
55
|
+
env_path = os.environ.get("FLOW_AGENTS_ENGINE_PATH")
|
|
56
|
+
if env_path:
|
|
57
|
+
p = Path(env_path)
|
|
58
|
+
if p.is_file():
|
|
59
|
+
return node, str(p)
|
|
60
|
+
|
|
61
|
+
# 2. Relative to this source file (repo checkout: integrations/strands/flow_agents_strands/)
|
|
62
|
+
candidate = Path(__file__).parent.parent.parent.parent / "scripts" / "hooks" / "run-hook.js"
|
|
63
|
+
if candidate.is_file():
|
|
64
|
+
return node, str(candidate)
|
|
65
|
+
|
|
66
|
+
# 3. npm-installed package layout
|
|
67
|
+
cwd = Path.cwd()
|
|
68
|
+
for parent in [cwd, *cwd.parents]:
|
|
69
|
+
candidate = (
|
|
70
|
+
parent / "node_modules" / "@kontourai" / "flow-agents" / "scripts" / "hooks" / "run-hook.js"
|
|
71
|
+
)
|
|
72
|
+
if candidate.is_file():
|
|
73
|
+
return node, str(candidate)
|
|
74
|
+
|
|
75
|
+
return node, None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# Resolved at module import time; callers can override via constructor parameters.
|
|
79
|
+
_NODE_BIN, _RUN_HOOK_PATH = _find_engine_paths()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# Sentinel for "not provided" constructor parameters
|
|
83
|
+
_UNSET = object()
|
|
84
|
+
|
|
85
|
+
# ---------------------------------------------------------------------------
|
|
86
|
+
# Pure-Python fallback — mirrors config-protection.js PROTECTED_FILES
|
|
87
|
+
# ---------------------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
PROTECTED_FILES = frozenset(
|
|
90
|
+
[
|
|
91
|
+
# ESLint
|
|
92
|
+
".eslintrc",
|
|
93
|
+
".eslintrc.js",
|
|
94
|
+
".eslintrc.cjs",
|
|
95
|
+
".eslintrc.json",
|
|
96
|
+
".eslintrc.yml",
|
|
97
|
+
".eslintrc.yaml",
|
|
98
|
+
"eslint.config.js",
|
|
99
|
+
"eslint.config.mjs",
|
|
100
|
+
"eslint.config.cjs",
|
|
101
|
+
"eslint.config.ts",
|
|
102
|
+
"eslint.config.mts",
|
|
103
|
+
"eslint.config.cts",
|
|
104
|
+
# Prettier
|
|
105
|
+
".prettierrc",
|
|
106
|
+
".prettierrc.js",
|
|
107
|
+
".prettierrc.cjs",
|
|
108
|
+
".prettierrc.json",
|
|
109
|
+
".prettierrc.yml",
|
|
110
|
+
".prettierrc.yaml",
|
|
111
|
+
"prettier.config.js",
|
|
112
|
+
"prettier.config.cjs",
|
|
113
|
+
"prettier.config.mjs",
|
|
114
|
+
# Biome
|
|
115
|
+
"biome.json",
|
|
116
|
+
"biome.jsonc",
|
|
117
|
+
# Ruff
|
|
118
|
+
".ruff.toml",
|
|
119
|
+
"ruff.toml",
|
|
120
|
+
# Others
|
|
121
|
+
".shellcheckrc",
|
|
122
|
+
".stylelintrc",
|
|
123
|
+
".stylelintrc.json",
|
|
124
|
+
".stylelintrc.yml",
|
|
125
|
+
".markdownlint.json",
|
|
126
|
+
".markdownlint.yaml",
|
|
127
|
+
".markdownlintrc",
|
|
128
|
+
]
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
_BLOCK_REASON_TEMPLATE = (
|
|
132
|
+
"BLOCKED: Modifying {basename} is not allowed. "
|
|
133
|
+
"Fix the source code to satisfy linter/formatter rules instead of "
|
|
134
|
+
"weakening the config. If this is a legitimate config change, "
|
|
135
|
+
"disable the config-protection policy gate temporarily."
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
# Write-like tool names — both the engine and the Python fallback only gate
|
|
139
|
+
# write-like tools. Reads on protected files are always allowed.
|
|
140
|
+
_WRITE_TOOLS = frozenset(
|
|
141
|
+
{
|
|
142
|
+
"edit",
|
|
143
|
+
"write",
|
|
144
|
+
"fs_write",
|
|
145
|
+
"apply_patch",
|
|
146
|
+
"create_file",
|
|
147
|
+
"str_replace_editor",
|
|
148
|
+
}
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _python_config_protection(
|
|
153
|
+
tool_name: str,
|
|
154
|
+
tool_input: dict,
|
|
155
|
+
protected: frozenset = PROTECTED_FILES,
|
|
156
|
+
) -> Optional[str]:
|
|
157
|
+
"""
|
|
158
|
+
Pure-Python evaluation of config-protection.
|
|
159
|
+
|
|
160
|
+
Mirrors the logic in scripts/hooks/config-protection.js.
|
|
161
|
+
Called when Node.js is unavailable or a custom protected set is in use.
|
|
162
|
+
"""
|
|
163
|
+
if tool_name.lower() not in _WRITE_TOOLS:
|
|
164
|
+
return None
|
|
165
|
+
file_path = tool_input.get("path") or tool_input.get("file_path") or ""
|
|
166
|
+
if not file_path:
|
|
167
|
+
return None
|
|
168
|
+
basename = Path(file_path).name
|
|
169
|
+
if basename in protected:
|
|
170
|
+
return _BLOCK_REASON_TEMPLATE.format(basename=basename)
|
|
171
|
+
return None
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# ---------------------------------------------------------------------------
|
|
175
|
+
# Subprocess binding to the engine contract
|
|
176
|
+
# ---------------------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
def _invoke_engine(
|
|
179
|
+
hook_id: str,
|
|
180
|
+
hook_script: str,
|
|
181
|
+
payload: dict,
|
|
182
|
+
*,
|
|
183
|
+
node_bin: str,
|
|
184
|
+
run_hook_path: str,
|
|
185
|
+
extra_env: Optional[dict] = None,
|
|
186
|
+
) -> tuple[int, str, str]:
|
|
187
|
+
"""
|
|
188
|
+
Invoke the canonical engine via subprocess.
|
|
189
|
+
|
|
190
|
+
Returns (exit_code, stdout, stderr).
|
|
191
|
+
On subprocess errors returns (1, "", "<error>") so callers can fail-open.
|
|
192
|
+
"""
|
|
193
|
+
env = dict(os.environ)
|
|
194
|
+
if extra_env:
|
|
195
|
+
env.update(extra_env)
|
|
196
|
+
env["FLOW_AGENTS_HOOK_RUNTIME"] = "strands"
|
|
197
|
+
|
|
198
|
+
# run-hook.js resolves hook_script relative to its own directory
|
|
199
|
+
hooks_dir = str(Path(run_hook_path).parent)
|
|
200
|
+
|
|
201
|
+
try:
|
|
202
|
+
result = subprocess.run(
|
|
203
|
+
[node_bin, run_hook_path, hook_id, hook_script],
|
|
204
|
+
input=json.dumps(payload),
|
|
205
|
+
capture_output=True,
|
|
206
|
+
text=True,
|
|
207
|
+
timeout=15,
|
|
208
|
+
env=env,
|
|
209
|
+
cwd=hooks_dir,
|
|
210
|
+
)
|
|
211
|
+
return result.returncode, result.stdout, result.stderr
|
|
212
|
+
except subprocess.TimeoutExpired:
|
|
213
|
+
return 1, "", "[policy] engine subprocess timed out"
|
|
214
|
+
except OSError as exc:
|
|
215
|
+
return 1, "", f"[policy] engine subprocess failed: {exc}"
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
# ---------------------------------------------------------------------------
|
|
219
|
+
# PolicyGate
|
|
220
|
+
# ---------------------------------------------------------------------------
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
class PolicyGate:
|
|
224
|
+
"""
|
|
225
|
+
Evaluates tool-call policy gates.
|
|
226
|
+
|
|
227
|
+
Designed for use inside BeforeToolCallEvent callbacks; does NOT depend on
|
|
228
|
+
any Strands import so it is fully testable without the SDK installed.
|
|
229
|
+
|
|
230
|
+
Primary mode: spawns ``node run-hook.js config-protection.js`` to delegate
|
|
231
|
+
to the engine contract (contract_version "1.0"). The tool-name pre-filter
|
|
232
|
+
is applied in Python before calling the engine.
|
|
233
|
+
|
|
234
|
+
Fallback mode: if Node.js or the engine script is unavailable, degrades to
|
|
235
|
+
the built-in Python implementation and emits a one-time RuntimeWarning.
|
|
236
|
+
|
|
237
|
+
Custom protected_files: if a non-default frozenset is passed, Python
|
|
238
|
+
evaluation is used directly (the engine subprocess cannot accept a
|
|
239
|
+
runtime-custom set). This is intended for tests and local override only.
|
|
240
|
+
|
|
241
|
+
The ``_node_bin`` and ``_run_hook_path`` constructor parameters allow tests
|
|
242
|
+
to inject fakes without touching the filesystem.
|
|
243
|
+
"""
|
|
244
|
+
|
|
245
|
+
def __init__(
|
|
246
|
+
self,
|
|
247
|
+
protected_files: Optional[frozenset] = None,
|
|
248
|
+
_node_bin: object = _UNSET,
|
|
249
|
+
_run_hook_path: object = _UNSET,
|
|
250
|
+
) -> None:
|
|
251
|
+
# Allow tests to inject explicit values (including None to force fallback).
|
|
252
|
+
# _UNSET means "use the module-level resolved value".
|
|
253
|
+
self._node_bin: Optional[str] = (
|
|
254
|
+
_node_bin if _node_bin is not _UNSET else _NODE_BIN # type: ignore[assignment]
|
|
255
|
+
)
|
|
256
|
+
self._run_hook_path: Optional[str] = (
|
|
257
|
+
_run_hook_path if _run_hook_path is not _UNSET else _RUN_HOOK_PATH # type: ignore[assignment]
|
|
258
|
+
)
|
|
259
|
+
# Custom protected set: bypass engine subprocess, use Python directly.
|
|
260
|
+
self._custom_protected: Optional[frozenset] = protected_files
|
|
261
|
+
self._warned_fallback = False
|
|
262
|
+
|
|
263
|
+
@property
|
|
264
|
+
def _engine_available(self) -> bool:
|
|
265
|
+
return bool(self._node_bin and self._run_hook_path)
|
|
266
|
+
|
|
267
|
+
def _warn_fallback(self) -> None:
|
|
268
|
+
if not self._warned_fallback:
|
|
269
|
+
self._warned_fallback = True
|
|
270
|
+
warnings.warn(
|
|
271
|
+
"flow-agents-strands: Node.js or the Flow Agents engine script is "
|
|
272
|
+
"not available. Policy gates are degrading to the built-in Python "
|
|
273
|
+
"fallback (fail-open for unknown cases). Install Node.js and ensure "
|
|
274
|
+
"the @kontourai/flow-agents package is reachable to use the canonical "
|
|
275
|
+
"engine contract. See README.md §Limitations.",
|
|
276
|
+
RuntimeWarning,
|
|
277
|
+
stacklevel=4,
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
def check_tool_call(
|
|
281
|
+
self,
|
|
282
|
+
tool_name: str,
|
|
283
|
+
tool_input: dict,
|
|
284
|
+
) -> Optional[str]:
|
|
285
|
+
"""
|
|
286
|
+
Evaluate policy for a tool call.
|
|
287
|
+
|
|
288
|
+
Returns None if the call is allowed, or a block-reason string if it
|
|
289
|
+
should be cancelled (the string becomes the cancel_tool message).
|
|
290
|
+
"""
|
|
291
|
+
return self._check_config_protection(tool_name, tool_input)
|
|
292
|
+
|
|
293
|
+
def _check_config_protection(
|
|
294
|
+
self,
|
|
295
|
+
tool_name: str,
|
|
296
|
+
tool_input: dict,
|
|
297
|
+
) -> Optional[str]:
|
|
298
|
+
"""
|
|
299
|
+
Invoke config-protection.
|
|
300
|
+
|
|
301
|
+
Tool-name pre-filter: only write-like tools are gated. Reads on
|
|
302
|
+
protected files are always allowed (this is the same rule as the JS
|
|
303
|
+
engine — the engine short-circuits for the same reason; the pre-filter
|
|
304
|
+
avoids the subprocess round-trip for non-write tools).
|
|
305
|
+
|
|
306
|
+
If a custom protected_files set was passed to the constructor, Python
|
|
307
|
+
evaluation is used instead of the engine subprocess.
|
|
308
|
+
|
|
309
|
+
If Node.js or the engine script is unavailable, falls back to Python
|
|
310
|
+
evaluation with the default PROTECTED_FILES set.
|
|
311
|
+
"""
|
|
312
|
+
# Tool-name pre-filter (read tools always allowed)
|
|
313
|
+
if tool_name.lower() not in _WRITE_TOOLS:
|
|
314
|
+
return None
|
|
315
|
+
|
|
316
|
+
# Custom protected set → Python evaluation only
|
|
317
|
+
if self._custom_protected is not None:
|
|
318
|
+
return _python_config_protection(tool_name, tool_input, self._custom_protected)
|
|
319
|
+
|
|
320
|
+
# Engine subprocess → authoritative decision
|
|
321
|
+
if self._engine_available:
|
|
322
|
+
payload = {
|
|
323
|
+
"hook_event_name": "PreToolUse",
|
|
324
|
+
"tool_name": tool_name,
|
|
325
|
+
"tool_input": tool_input,
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
exit_code, stdout, stderr = _invoke_engine(
|
|
329
|
+
hook_id="config-protection",
|
|
330
|
+
hook_script="config-protection.js",
|
|
331
|
+
payload=payload,
|
|
332
|
+
node_bin=self._node_bin,
|
|
333
|
+
run_hook_path=self._run_hook_path,
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
if exit_code == 2:
|
|
337
|
+
reason = stderr.strip() or stdout.strip() or "BLOCKED: config-protection policy blocked this action."
|
|
338
|
+
return reason
|
|
339
|
+
|
|
340
|
+
if exit_code == 0:
|
|
341
|
+
return None
|
|
342
|
+
|
|
343
|
+
# Engine error (exit_code not 0 or 2) → fail-open
|
|
344
|
+
return None
|
|
345
|
+
|
|
346
|
+
# No engine available → Python fallback
|
|
347
|
+
self._warn_fallback()
|
|
348
|
+
return _python_config_protection(tool_name, tool_input)
|
|
@@ -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
|
+
)
|