arkaos 2.16.1 → 2.17.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 (36) hide show
  1. package/VERSION +1 -1
  2. package/config/agent-allowlists/_base.yaml +7 -0
  3. package/config/agent-allowlists/laravel.yaml +9 -0
  4. package/config/agent-allowlists/node.yaml +7 -0
  5. package/config/agent-allowlists/nuxt.yaml +7 -0
  6. package/config/agent-allowlists/python.yaml +7 -0
  7. package/config/hooks/agent-provision.sh +135 -0
  8. package/config/mcp-policy.yaml +36 -0
  9. package/config/settings-template.json +12 -0
  10. package/config/standards/claude-md-overlays/laravel.md +8 -0
  11. package/config/standards/claude-md-overlays/node.md +7 -0
  12. package/config/standards/claude-md-overlays/nuxt.md +8 -0
  13. package/config/standards/claude-md-overlays/python.md +8 -0
  14. package/core/sync/__pycache__/agent_provisioner.cpython-313.pyc +0 -0
  15. package/core/sync/__pycache__/ai_mcp_decider.cpython-313.pyc +0 -0
  16. package/core/sync/__pycache__/content_merger.cpython-313.pyc +0 -0
  17. package/core/sync/__pycache__/content_syncer.cpython-313.pyc +0 -0
  18. package/core/sync/__pycache__/descriptor_syncer.cpython-313.pyc +0 -0
  19. package/core/sync/__pycache__/engine.cpython-313.pyc +0 -0
  20. package/core/sync/__pycache__/mcp_optimizer.cpython-313.pyc +0 -0
  21. package/core/sync/__pycache__/policy_loader.cpython-313.pyc +0 -0
  22. package/core/sync/__pycache__/reporter.cpython-313.pyc +0 -0
  23. package/core/sync/__pycache__/schema.cpython-313.pyc +0 -0
  24. package/core/sync/__pycache__/self_healing.cpython-313.pyc +0 -0
  25. package/core/sync/agent_provisioner.py +150 -0
  26. package/core/sync/ai_mcp_decider.py +86 -0
  27. package/core/sync/content_merger.py +100 -0
  28. package/core/sync/content_syncer.py +167 -0
  29. package/core/sync/engine.py +20 -0
  30. package/core/sync/mcp_optimizer.py +187 -0
  31. package/core/sync/policy_loader.py +94 -0
  32. package/core/sync/reporter.py +49 -1
  33. package/core/sync/schema.py +37 -0
  34. package/core/sync/self_healing.py +47 -0
  35. package/package.json +1 -1
  36. package/pyproject.toml +1 -1
package/VERSION CHANGED
@@ -1 +1 @@
1
- 2.16.1
1
+ 2.17.0
@@ -0,0 +1,7 @@
1
+ # Baseline agents every project gets regardless of stack.
2
+ stack: _base
3
+ baseline:
4
+ - strategy-director
5
+ - cqo
6
+ - copy-director
7
+ - tech-ux-director
@@ -0,0 +1,9 @@
1
+ stack: laravel
2
+ baseline:
3
+ - backend-dev
4
+ - senior-dev
5
+ - architect
6
+ - qa
7
+ - devops
8
+ - security
9
+ - analyst
@@ -0,0 +1,7 @@
1
+ stack: node
2
+ baseline:
3
+ - backend-dev
4
+ - senior-dev
5
+ - architect
6
+ - qa
7
+ - devops
@@ -0,0 +1,7 @@
1
+ stack: nuxt
2
+ baseline:
3
+ - frontend-dev
4
+ - backend-dev
5
+ - architect
6
+ - qa
7
+ - devops
@@ -0,0 +1,7 @@
1
+ stack: python
2
+ baseline:
3
+ - senior-dev
4
+ - architect
5
+ - qa
6
+ - devops
7
+ - analyst
@@ -0,0 +1,135 @@
1
+ #!/usr/bin/env bash
2
+ # ArkaOS PreToolUse hook for dynamic agent provisioning.
3
+ # Intercepts Task tool calls: if subagent_type is not present in the
4
+ # project's .claude/agents/, copies it from ArkaOS core when available,
5
+ # or blocks with an approval-request message when the agent must be
6
+ # created via `/platform-arka agent provision <name>`.
7
+
8
+ set -euo pipefail
9
+
10
+ # Hook contract: stdin is a JSON payload with fields tool_name + tool_input.
11
+ payload="$(cat)"
12
+
13
+ tool_name="$(echo "$payload" | jq -r '.tool_name // ""')"
14
+ if [ "$tool_name" != "Task" ]; then
15
+ exit 0
16
+ fi
17
+
18
+ subagent_type="$(echo "$payload" | jq -r '.tool_input.subagent_type // ""')"
19
+ if [ -z "$subagent_type" ] || [ "$subagent_type" = "null" ]; then
20
+ exit 0
21
+ fi
22
+
23
+ # Strict allowlist: lowercase alphanumeric + hyphens, max 64 chars, must start with letter.
24
+ if [[ ! "$subagent_type" =~ ^[a-z][a-z0-9-]{0,63}$ ]]; then
25
+ # Invalid name — skip hook (do not block) so legitimate workflows continue;
26
+ # the Task dispatch itself will fail naturally if the agent truly doesn't exist.
27
+ exit 0
28
+ fi
29
+
30
+ project_root="$(pwd)"
31
+ project_agents_dir="$project_root/.claude/agents"
32
+ target="$project_agents_dir/${subagent_type}.md"
33
+
34
+ if [ -f "$target" ]; then
35
+ exit 0
36
+ fi
37
+
38
+ # Agent missing locally — try copying from ArkaOS core.
39
+ core_root="${ARKAOS_CORE_ROOT:-}"
40
+ if [ -z "$core_root" ]; then
41
+ core_root="$(npm root -g 2>/dev/null)/arkaos"
42
+ fi
43
+
44
+ if [ -d "$core_root/departments" ]; then
45
+ mkdir -p "$project_agents_dir"
46
+ set +e
47
+ python3 - "$core_root" "$subagent_type" "$target" <<'PY'
48
+ import os, re, sys
49
+ from pathlib import Path
50
+
51
+ core = Path(sys.argv[1]).resolve()
52
+ name = sys.argv[2]
53
+ target = Path(sys.argv[3]).resolve()
54
+
55
+ if not re.fullmatch(r"[a-z][a-z0-9-]{0,63}", name):
56
+ sys.exit(2)
57
+
58
+ departments_root = (core / "departments").resolve()
59
+ if not departments_root.exists():
60
+ sys.exit(2)
61
+
62
+ # Ensure target stays within .claude/agents of the project
63
+ expected_parent = target.parent.resolve()
64
+ if target.suffix != ".md" or expected_parent.name != "agents":
65
+ sys.exit(2)
66
+
67
+ yaml_path = None
68
+ md_path = None
69
+ for dept in departments_root.iterdir():
70
+ agents = dept / "agents"
71
+ if not agents.is_dir():
72
+ continue
73
+ y = (agents / f"{name}.yaml").resolve()
74
+ m = (agents / f"{name}.md").resolve()
75
+ if y.exists() and yaml_path is None:
76
+ try:
77
+ y.relative_to(departments_root)
78
+ except ValueError:
79
+ continue
80
+ yaml_path = y
81
+ if m.exists() and md_path is None:
82
+ try:
83
+ m.relative_to(departments_root)
84
+ except ValueError:
85
+ continue
86
+ md_path = m
87
+
88
+ if yaml_path is None and md_path is None:
89
+ sys.exit(2)
90
+
91
+ parts = []
92
+ if yaml_path is not None:
93
+ parts.append("---")
94
+ parts.append(yaml_path.read_text().strip())
95
+ parts.append("---")
96
+ if md_path is not None:
97
+ parts.append(md_path.read_text().rstrip())
98
+
99
+ content = "\n".join(parts) + "\n"
100
+
101
+ # Atomic write: temp file in same dir, then os.replace.
102
+ tmp = target.with_suffix(".md.tmp")
103
+ tmp.write_text(content)
104
+ os.replace(tmp, target)
105
+ PY
106
+ rc=$?
107
+ set -e
108
+
109
+ case "$rc" in
110
+ 0)
111
+ echo "[arka:provisioned] Copied agent '$subagent_type' from ArkaOS core." >&2
112
+ exit 0
113
+ ;;
114
+ 2)
115
+ # Agent not found in core; fall through to approval-request message below.
116
+ ;;
117
+ *)
118
+ echo "[arka:provision-error] Unexpected error ($rc) while provisioning '$subagent_type'. Dispatch not blocked." >&2
119
+ exit 1
120
+ ;;
121
+ esac
122
+ fi
123
+
124
+ # Agent not in project and not in core — surface an approval-request.
125
+ cat >&2 <<MSG
126
+ [arka:provision-needed] Agent '$subagent_type' is not installed in this
127
+ project and does not exist in ArkaOS core. To create it, run:
128
+
129
+ /platform-arka agent provision $subagent_type
130
+
131
+ This opens the Skill Architect flow which drafts the agent YAML with
132
+ 4-framework DNA, goes through Quality Gate, and commits to core before
133
+ propagating to the project. Blocking dispatch until the agent exists.
134
+ MSG
135
+ exit 2
@@ -0,0 +1,36 @@
1
+ # ArkaOS MCP Policy Registry
2
+ # Controls which MCPs load eagerly vs deferred per project stack/ecosystem.
3
+ # Rules evaluated top-to-bottom; first match wins. "ambiguous: ['*']" means
4
+ # all other MCPs defer to AI (or fallback heuristic when AI unavailable).
5
+
6
+ version: 1
7
+ policies:
8
+ - match:
9
+ stack_includes: [laravel, php]
10
+ active: [context7, gh-grep, postgres, supabase]
11
+ deferred: [canva, clickup, firecrawl, chrome, gmail, calendar, claude-in-chrome]
12
+ ambiguous: []
13
+
14
+ - match:
15
+ stack_includes: [nuxt, vue, react, next]
16
+ active: [context7, gh-grep, playwright, claude-in-chrome]
17
+ deferred: [postgres, supabase, canva, clickup, gmail, calendar]
18
+ ambiguous: []
19
+
20
+ - match:
21
+ ecosystem: marketing
22
+ active: [canva, gmail, calendar, firecrawl, clickup]
23
+ deferred: [postgres, supabase, playwright]
24
+ ambiguous: []
25
+
26
+ - match:
27
+ ecosystem: content
28
+ active: [canva, firecrawl, youtube-transcript]
29
+ deferred: [postgres, clickup]
30
+ ambiguous: []
31
+
32
+ - match:
33
+ default: true
34
+ active: [context7]
35
+ deferred: []
36
+ ambiguous: ["*"]
@@ -59,6 +59,18 @@
59
59
  }
60
60
  ]
61
61
  }
62
+ ],
63
+ "PreToolUse": [
64
+ {
65
+ "matcher": "Task",
66
+ "hooks": [
67
+ {
68
+ "type": "command",
69
+ "command": "{{HOOKS_DIR}}/agent-provision.sh",
70
+ "timeout": 10
71
+ }
72
+ ]
73
+ }
62
74
  ]
63
75
  }
64
76
  }
@@ -0,0 +1,8 @@
1
+ ## Laravel Stack Conventions
2
+
3
+ - Services + Repositories pattern; no logic in controllers.
4
+ - Form Requests for all input validation.
5
+ - API Resources for response shaping.
6
+ - Feature Tests with RefreshDatabase trait.
7
+ - Eloquent relationships over raw joins.
8
+ - Conventional commits: `feat(scope): ...`, `fix(scope): ...`.
@@ -0,0 +1,7 @@
1
+ ## Node.js Stack Conventions
2
+
3
+ - ESM modules (import/export); no CommonJS `require()`.
4
+ - Support Node and Bun runtimes when writing CLI tooling.
5
+ - Graceful fallbacks when optional dependencies are missing.
6
+ - All paths via `os.homedir()` or `path.join`; never hardcoded.
7
+ - No interactive prompts in headless/CI runs.
@@ -0,0 +1,8 @@
1
+ ## Nuxt / Vue Stack Conventions
2
+
3
+ - Composition API only; no Options API.
4
+ - TypeScript everywhere; no plain JS Vue files.
5
+ - `composables/` for shared reactive logic.
6
+ - `useFetch`/`useAsyncData` for server-side data.
7
+ - `~` alias for project root imports.
8
+ - Tailwind for styling; avoid scoped styles unless necessary.
@@ -0,0 +1,8 @@
1
+ ## Python Stack Conventions
2
+
3
+ - Type hints on every function signature.
4
+ - Pydantic for validation; dataclasses for pure data.
5
+ - `pytest` with fixtures; no `unittest.TestCase`.
6
+ - Functions under 30 lines; one responsibility.
7
+ - Docstrings on public API only; self-documenting code elsewhere.
8
+ - Virtual environments; never global `pip install`.
@@ -0,0 +1,150 @@
1
+ """Agent provisioner — copies baseline agents into each project's .claude/agents/.
2
+
3
+ Resolves stack-based allowlists (plus the _base allowlist applied to every
4
+ project), locates source agent files under departments/**/agents/, and
5
+ materializes them as flat markdown files with YAML frontmatter the project's
6
+ Claude Code can consume.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ import re
13
+ from pathlib import Path
14
+
15
+ import yaml
16
+
17
+ from core.sync.schema import AgentProvisionResult, Project
18
+
19
+
20
+ def _core_root() -> Path:
21
+ env = os.environ.get("ARKAOS_CORE_ROOT")
22
+ if env:
23
+ return Path(env)
24
+ return Path(__file__).resolve().parents[2]
25
+
26
+
27
+ def resolve_allowlist(stack: list[str]) -> list[str]:
28
+ """Return the union of baseline agent names for the given stack tokens."""
29
+ core = _core_root()
30
+ allowlist_dir = core / "config" / "agent-allowlists"
31
+ agents: set[str] = set()
32
+
33
+ _extend_from_file(allowlist_dir / "_base.yaml", agents)
34
+ for stack_name in stack:
35
+ _extend_from_file(allowlist_dir / f"{stack_name.lower()}.yaml", agents)
36
+
37
+ return sorted(agents)
38
+
39
+
40
+ def sync_project_agents(project: Project) -> AgentProvisionResult:
41
+ """Materialize baseline agent markdown files in <project>/.claude/agents/."""
42
+ try:
43
+ return _do_sync(project)
44
+ except Exception as exc: # noqa: BLE001
45
+ return AgentProvisionResult(
46
+ path=project.path, status="error", error=str(exc)
47
+ )
48
+
49
+
50
+ def sync_all_agents(projects: list[Project]) -> list[AgentProvisionResult]:
51
+ return [sync_project_agents(p) for p in projects]
52
+
53
+
54
+ def _extend_from_file(path: Path, agents: set[str]) -> None:
55
+ if not path.exists():
56
+ return
57
+ try:
58
+ data = yaml.safe_load(path.read_text()) or {}
59
+ except yaml.YAMLError:
60
+ return
61
+ for name in data.get("baseline", []) or []:
62
+ if isinstance(name, str):
63
+ agents.add(name)
64
+
65
+
66
+ def _do_sync(project: Project) -> AgentProvisionResult:
67
+ core = _core_root()
68
+ agents_dir = Path(project.path) / ".claude" / "agents"
69
+ agents_dir.mkdir(parents=True, exist_ok=True)
70
+
71
+ allowlist = resolve_allowlist(project.stack)
72
+ added, unchanged, errored = _apply_allowlist(core, agents_dir, allowlist)
73
+
74
+ status = _status_from_counts(added, unchanged, errored)
75
+ return AgentProvisionResult(
76
+ path=project.path,
77
+ status=status,
78
+ agents_added=added,
79
+ agents_unchanged=unchanged,
80
+ agents_errored=errored,
81
+ )
82
+
83
+
84
+ def _apply_allowlist(
85
+ core: Path, agents_dir: Path, allowlist: list[str]
86
+ ) -> tuple[list[str], list[str], list[str]]:
87
+ added: list[str] = []
88
+ unchanged: list[str] = []
89
+ errored: list[str] = []
90
+
91
+ for name in allowlist:
92
+ rendered = _render_agent(core, name)
93
+ if rendered is None:
94
+ errored.append(name)
95
+ continue
96
+
97
+ target = agents_dir / f"{name}.md"
98
+ if target.exists() and target.read_text() == rendered:
99
+ unchanged.append(name)
100
+ continue
101
+ target.write_text(rendered)
102
+ added.append(name)
103
+
104
+ return added, unchanged, errored
105
+
106
+
107
+ def _status_from_counts(
108
+ added: list[str], unchanged: list[str], errored: list[str]
109
+ ) -> str:
110
+ if errored:
111
+ return "error"
112
+ if added:
113
+ return "updated"
114
+ return "unchanged"
115
+
116
+
117
+ def _render_agent(core: Path, name: str) -> str | None:
118
+ yaml_path = _find_agent_file(core, name, ".yaml")
119
+ md_path = _find_agent_file(core, name, ".md")
120
+
121
+ if yaml_path is None and md_path is None:
122
+ return None
123
+
124
+ parts: list[str] = []
125
+ if yaml_path is not None:
126
+ parts.append("---")
127
+ parts.append(yaml_path.read_text().strip())
128
+ parts.append("---")
129
+ if md_path is not None:
130
+ parts.append(md_path.read_text().rstrip())
131
+
132
+ return "\n".join(parts) + "\n"
133
+
134
+
135
+ def _find_agent_file(core: Path, name: str, suffix: str) -> Path | None:
136
+ # Defense in depth: reject names that could escape the agents dir.
137
+ if not re.fullmatch(r"[a-z][a-z0-9-]{0,63}", name):
138
+ return None
139
+ departments_root = (core / "departments").resolve()
140
+ if not departments_root.exists():
141
+ return None
142
+ for dept in departments_root.iterdir():
143
+ candidate = (dept / "agents" / f"{name}{suffix}").resolve()
144
+ try:
145
+ candidate.relative_to(departments_root)
146
+ except ValueError:
147
+ continue
148
+ if candidate.exists():
149
+ return candidate
150
+ return None
@@ -0,0 +1,86 @@
1
+ """AI-backed decider for MCPs the policy could not classify.
2
+
3
+ Falls back to deterministic heuristic (defer all unknowns) when the AI
4
+ is unavailable or a call fails. Decisions are cached on disk keyed by
5
+ (stack, ecosystem, mcp_name) to guarantee idempotence across runs.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import json
12
+ from pathlib import Path
13
+ from typing import Callable
14
+
15
+
16
+ class AIUnavailable(RuntimeError):
17
+ pass
18
+
19
+
20
+ AiCaller = Callable[[str, list[str], str | None], str]
21
+
22
+
23
+ def decide_ambiguous(
24
+ ambiguous: list[str],
25
+ stack: list[str],
26
+ ecosystem: str | None,
27
+ cache_path: Path,
28
+ call_ai: AiCaller | None,
29
+ ) -> dict[str, str]:
30
+ if not ambiguous:
31
+ return {}
32
+
33
+ cache = _load_cache(cache_path)
34
+ result: dict[str, str] = {}
35
+
36
+ for mcp in ambiguous:
37
+ key = _cache_key(mcp, stack, ecosystem)
38
+ cached = cache.get(key)
39
+ if cached in {"active", "deferred"}:
40
+ result[mcp] = cached
41
+ continue
42
+
43
+ decision = _resolve(mcp, stack, ecosystem, call_ai)
44
+ result[mcp] = decision
45
+ cache[key] = decision
46
+
47
+ _save_cache(cache_path, cache)
48
+ return result
49
+
50
+
51
+ def _resolve(
52
+ mcp: str,
53
+ stack: list[str],
54
+ ecosystem: str | None,
55
+ call_ai: AiCaller | None,
56
+ ) -> str:
57
+ if call_ai is None:
58
+ return "deferred"
59
+ try:
60
+ raw = call_ai(mcp, stack, ecosystem)
61
+ except AIUnavailable:
62
+ return "deferred"
63
+ return raw if raw in {"active", "deferred"} else "deferred"
64
+
65
+
66
+ def _cache_key(mcp: str, stack: list[str], ecosystem: str | None) -> str:
67
+ payload = json.dumps(
68
+ {"mcp": mcp, "stack": sorted(stack), "ecosystem": ecosystem},
69
+ sort_keys=True,
70
+ separators=(",", ":"),
71
+ )
72
+ return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:16]
73
+
74
+
75
+ def _load_cache(path: Path) -> dict[str, str]:
76
+ if not path.exists():
77
+ return {}
78
+ try:
79
+ return json.loads(path.read_text())
80
+ except (json.JSONDecodeError, OSError):
81
+ return {}
82
+
83
+
84
+ def _save_cache(path: Path, data: dict[str, str]) -> None:
85
+ path.parent.mkdir(parents=True, exist_ok=True)
86
+ path.write_text(json.dumps(data, indent=2, sort_keys=True))
@@ -0,0 +1,100 @@
1
+ """Managed-region content merger for the ArkaOS Sync Engine.
2
+
3
+ Merges core-owned content into project files while preserving any
4
+ project-authored content outside the managed region. Uses HTML comment
5
+ markers to delimit the managed block.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import re
12
+ from dataclasses import dataclass
13
+ from typing import Literal
14
+
15
+ _START_RE = re.compile(
16
+ r"<!--\s*arkaos:managed:start(?:\s+version=(?P<version>\S+))?"
17
+ r"(?:\s+hash=(?P<hash>[0-9a-f]{12}))?\s*-->",
18
+ )
19
+ _END_RE = re.compile(r"<!--\s*arkaos:managed:end\s*-->")
20
+
21
+
22
+ @dataclass
23
+ class MergeResult:
24
+ """Outcome of a managed-region merge operation."""
25
+
26
+ status: Literal["updated", "unchanged", "error"]
27
+ new_text: str
28
+ error: str | None = None
29
+
30
+
31
+ def compute_managed_hash(content: str) -> str:
32
+ """Return the first 12 hex chars of sha256 over managed content."""
33
+ return hashlib.sha256(content.encode("utf-8")).hexdigest()[:12]
34
+
35
+
36
+ def merge_managed_content(
37
+ target_text: str, managed_content: str, version: str
38
+ ) -> MergeResult:
39
+ """Merge managed_content into target_text inside the managed region.
40
+
41
+ Returns status "updated" when the file changes, "unchanged" when the
42
+ new hash matches the existing one, or "error" when markers are
43
+ unbalanced.
44
+ """
45
+ starts = list(_START_RE.finditer(target_text))
46
+ ends = list(_END_RE.finditer(target_text))
47
+
48
+ if len(starts) != len(ends):
49
+ return MergeResult(
50
+ status="error",
51
+ new_text=target_text,
52
+ error=f"unbalanced markers: {len(starts)} starts, {len(ends)} ends",
53
+ )
54
+
55
+ if len(starts) > 1:
56
+ return MergeResult(
57
+ status="error",
58
+ new_text=target_text,
59
+ error="multiple managed blocks are not supported",
60
+ )
61
+
62
+ new_hash = compute_managed_hash(managed_content)
63
+ new_block = _render_block(managed_content, version, new_hash)
64
+
65
+ if not starts:
66
+ return _prepend_block(target_text, new_block)
67
+
68
+ start_match = starts[0]
69
+ end_match = ends[0]
70
+ if end_match.start() < start_match.end():
71
+ return MergeResult(
72
+ status="error",
73
+ new_text=target_text,
74
+ error="end marker appears before start marker",
75
+ )
76
+
77
+ existing_hash = start_match.group("hash")
78
+ if existing_hash == new_hash:
79
+ return MergeResult(status="unchanged", new_text=target_text)
80
+
81
+ new_text = (
82
+ target_text[: start_match.start()]
83
+ + new_block
84
+ + target_text[end_match.end() :]
85
+ )
86
+ return MergeResult(status="updated", new_text=new_text)
87
+
88
+
89
+ def _render_block(content: str, version: str, content_hash: str) -> str:
90
+ return (
91
+ f"<!-- arkaos:managed:start version={version} hash={content_hash} -->\n"
92
+ f"{content}\n"
93
+ "<!-- arkaos:managed:end -->"
94
+ )
95
+
96
+
97
+ def _prepend_block(target_text: str, new_block: str) -> MergeResult:
98
+ separator = "\n\n" if target_text else "\n"
99
+ new_text = f"{new_block}{separator}{target_text}" if target_text else f"{new_block}\n"
100
+ return MergeResult(status="updated", new_text=new_text)