@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.
Files changed (85) hide show
  1. package/.github/dependabot.yml +23 -0
  2. package/.github/workflows/release-please.yml +31 -0
  3. package/.github/workflows/runtime-compat.yml +118 -0
  4. package/CHANGELOG.md +23 -0
  5. package/CONTRIBUTING.md +4 -0
  6. package/README.md +53 -10
  7. package/build/src/cli/init.js +215 -5
  8. package/build/src/cli/utterance-check.js +65 -1
  9. package/build/src/tools/build-universal-bundles.js +268 -0
  10. package/build/src/tools/filter-installed-packs.js +3 -0
  11. package/build/src/tools/validate-source-tree.js +5 -1
  12. package/context/scripts/telemetry/lib/config.sh +5 -1
  13. package/context/settings/flow-agents-settings.json +7 -0
  14. package/docs/context-map.md +1 -0
  15. package/docs/index.md +45 -4
  16. package/docs/integrations/conformance.md +246 -0
  17. package/docs/integrations/framework-adapter.md +275 -0
  18. package/docs/integrations/harness-install.md +213 -0
  19. package/docs/integrations/index.md +54 -0
  20. package/docs/north-star.md +2 -2
  21. package/docs/spec/runtime-hook-surface.md +472 -0
  22. package/docs/survey-utterance-check.md +211 -94
  23. package/docs/vision.md +45 -0
  24. package/evals/acceptance/run.sh +4 -2
  25. package/evals/acceptance/test_opencode_harness.sh +121 -0
  26. package/evals/acceptance/test_pi_harness.sh +98 -0
  27. package/evals/integration/test_bundle_install.sh +226 -1
  28. package/evals/integration/test_bundle_lifecycle.sh +641 -0
  29. package/evals/integration/test_utterance_check.sh +291 -44
  30. package/evals/run.sh +2 -0
  31. package/evals/static/test_universal_bundles.sh +137 -2
  32. package/integrations/strands/README.md +256 -0
  33. package/integrations/strands/example.py +74 -0
  34. package/integrations/strands/flow_agents_strands/__init__.py +27 -0
  35. package/integrations/strands/flow_agents_strands/hooks.py +194 -0
  36. package/integrations/strands/flow_agents_strands/policy.py +348 -0
  37. package/integrations/strands/flow_agents_strands/steering.py +172 -0
  38. package/integrations/strands/flow_agents_strands/telemetry.py +238 -0
  39. package/integrations/strands/pyproject.toml +38 -0
  40. package/integrations/strands/tests/__init__.py +0 -0
  41. package/integrations/strands/tests/test_hooks.py +304 -0
  42. package/integrations/strands/tests/test_policy.py +315 -0
  43. package/integrations/strands/tests/test_telemetry.py +184 -0
  44. package/integrations/strands-ts/README.md +224 -0
  45. package/integrations/strands-ts/bin/conformance-shim.mjs +257 -0
  46. package/integrations/strands-ts/package.json +53 -0
  47. package/integrations/strands-ts/src/hooks.ts +208 -0
  48. package/integrations/strands-ts/src/index.ts +22 -0
  49. package/integrations/strands-ts/src/policy.ts +345 -0
  50. package/integrations/strands-ts/src/telemetry.ts +251 -0
  51. package/integrations/strands-ts/test/test-policy.ts +322 -0
  52. package/integrations/strands-ts/test/test-telemetry.ts +226 -0
  53. package/integrations/strands-ts/tsconfig.json +20 -0
  54. package/package.json +7 -2
  55. package/packaging/conformance/README.md +142 -0
  56. package/packaging/conformance/fixtures/config-protection--allow-no-path.json +18 -0
  57. package/packaging/conformance/fixtures/config-protection--allow-safe-file.json +20 -0
  58. package/packaging/conformance/fixtures/config-protection--block-biome.json +20 -0
  59. package/packaging/conformance/fixtures/config-protection--block-eslintrc.json +20 -0
  60. package/packaging/conformance/fixtures/quality-gate--allow-no-path.json +17 -0
  61. package/packaging/conformance/fixtures/quality-gate--allow-nonexistent-file.json +19 -0
  62. package/packaging/conformance/fixtures/stop-goal-fit--allow-clean-cwd.json +17 -0
  63. package/packaging/conformance/fixtures/stop-goal-fit--block-strict-mode.json +23 -0
  64. package/packaging/conformance/fixtures/stop-goal-fit--warn-active-delivery.json +21 -0
  65. package/packaging/conformance/fixtures/workflow-steering--allow-no-state.json +16 -0
  66. package/packaging/conformance/fixtures/workflow-steering--inject-active-state.json +29 -0
  67. package/packaging/conformance/fixtures/workflow-steering--inject-subagent-steering.json +25 -0
  68. package/packaging/conformance/package.json +4 -0
  69. package/packaging/conformance/run-conformance.js +322 -0
  70. package/packaging/manifest.json +59 -0
  71. package/schemas/flow-agents-settings.schema.json +48 -0
  72. package/scripts/README.md +4 -0
  73. package/scripts/dogfood.js +16 -0
  74. package/scripts/hooks/opencode-hook-adapter.js +123 -0
  75. package/scripts/hooks/opencode-telemetry-hook.js +101 -0
  76. package/scripts/hooks/pi-hook-adapter.js +123 -0
  77. package/scripts/hooks/pi-telemetry-hook.js +105 -0
  78. package/scripts/hooks/run-hook.js +8 -0
  79. package/scripts/hooks/utterance-check.js +124 -22
  80. package/scripts/telemetry/lib/config.sh +5 -1
  81. package/src/cli/init.ts +219 -6
  82. package/src/cli/utterance-check.ts +71 -1
  83. package/src/tools/build-universal-bundles.ts +266 -0
  84. package/src/tools/filter-installed-packs.ts +3 -0
  85. 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
+ )