syntaur 0.32.0 → 0.33.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "syntaur",
3
- "version": "0.32.0",
3
+ "version": "0.33.0",
4
4
  "description": "Project workflow CLI with dashboard, Claude Code plugin, and Codex plugin",
5
5
  "homepage": "https://github.com/prong-horn/syntaur#readme",
6
6
  "repository": {
@@ -21,6 +21,31 @@ Driven declaratively by the registry in `src/targets/registry.ts`.
21
21
  | **OpenClaw** | `AGENTS.md` | OpenClaw reads `AGENTS.md` (built on Pi) |
22
22
  | **Hermes Agent** | `SOUL.md` | Hermes reads `SOUL.md` / context files |
23
23
 
24
+ **User-authored agents:** end users can register a brand-new Tier-1+Tier-2 agent
25
+ WITHOUT a Syntaur release by dropping a JSON descriptor in `~/.syntaur/targets/`.
26
+ See `references/user-targets.md`.
27
+
28
+ ## Tier-3 deep enforcement plugins (pi / OpenClaw / Hermes)
29
+
30
+ Tier-3 brings the **same enforcement parity the Claude Code / Codex plugins have** —
31
+ write-boundary blocking + session cleanup + Syntaur slash commands — to agents that
32
+ support imperative plugins. Installed automatically when the agent is targeted
33
+ (`syntaur setup --target pi`); `syntaur doctor` reports Tier-3 install status.
34
+
35
+ | Agent | Plugin | Installed to | Enforcement |
36
+ |-------|--------|--------------|-------------|
37
+ | **Pi** | TypeScript extension (`platforms/pi/extensions/syntaur/`) | `~/.pi/agent/extensions/syntaur/` | `tool_call` → block out-of-boundary writes; `session_shutdown` → mark session stopped |
38
+ | **OpenClaw** | reuses the **pi** extension (runs on pi-coding-agent) | `~/.openclaw/extensions/syntaur/` | same as Pi |
39
+ | **Hermes** | Python plugin (`platforms/hermes/plugins/syntaur/`) | `~/.hermes/plugins/syntaur/` | `pre_tool_call` → log + best-effort block; `on_session_end` → mark session stopped |
40
+
41
+ Caveats (see each plugin's `README.md`): **OpenClaw** is assumed to run on
42
+ pi-coding-agent per the design memo — if a build diverges to its own plugin format,
43
+ only the install dir needs repointing. **Hermes** `pre_tool_call` blocking is
44
+ version-dependent (documented primarily as an observer hook), so the Hermes plugin
45
+ logs every violation in addition to returning a deny signal; verify hard-block
46
+ against your live runtime. The boundary logic for both is unit-tested in
47
+ `src/__tests__/pi-extension.test.ts` and `src/__tests__/hermes-plugin.test.ts`.
48
+
24
49
  ## Installing skills into any agent (Tier 1)
25
50
 
26
51
  Syntaur's `skills/` directory is a valid Agent Skills source, so the skills
@@ -99,15 +124,21 @@ To add support for a new framework:
99
124
  - Export a render function returning the file content as a string
100
125
  - Embed protocol knowledge directly in the template literal (do not read files at runtime)
101
126
 
102
- 3. **Update barrel exports** in `src/templates/index.ts` -- add the new render function
103
- and param type.
127
+ 3. **Register the renderer** in `src/targets/renderers.ts` -- add a `RendererKey`
128
+ in `src/targets/types.ts` and map it to the render function in the `RENDERERS` table.
129
+
130
+ 4. **Add a descriptor** to the registry array in `src/targets/registry.ts` (the
131
+ `SUPPORTED_FRAMEWORKS` switch was removed in the Phase-1 registry refactor):
132
+ one `AgentTarget` with `id`, `displayName`, `detect`, optional `skillsDir`, and an
133
+ `instructions.files[]` listing each protocol file + its `renderer` key.
104
134
 
105
- 4. **Add a case** in `src/commands/setup-adapter.ts` for the new framework:
106
- - Add the framework name to `SUPPORTED_FRAMEWORKS`
107
- - Add the rendering and file-writing logic in the framework switch
135
+ **No-code alternative:** end users can register a Tier-1+Tier-2 agent WITHOUT a
136
+ Syntaur release by dropping a JSON descriptor in `~/.syntaur/targets/` -- see
137
+ `references/user-targets.md`. Code changes here are only needed for built-in
138
+ agents or new renderers.
108
139
 
109
- 5. **Add unit tests** in `src/__tests__/adapter-templates.test.ts` verifying
110
- the renderer produces correct output.
140
+ 5. **Add unit tests** in `src/__tests__/targets-registry.test.ts` /
141
+ `src/__tests__/adapter-templates.test.ts` verifying the renderer produces correct output.
111
142
 
112
143
  6. **Update this README** with the new framework in the table above.
113
144
 
@@ -0,0 +1,37 @@
1
+ # Syntaur plugin for Hermes Agent (Tier-3)
2
+
3
+ A self-contained Python plugin that brings Syntaur's Tier-3 enforcement to **Hermes Agent**, mirroring
4
+ the Claude Code / Codex bash hooks:
5
+
6
+ - **`pre_tool_call`** — detects writes (Hermes snake_case tools: `patch`, `write_file`, `edit_file`,
7
+ `create_file`, `apply_patch`) outside the active assignment boundary and **logs + best-effort blocks**
8
+ them. Boundary logic in `boundary.py` mirrors `platforms/claude-code/hooks/enforce-boundaries.sh`.
9
+ - **`on_session_end`** — marks the Syntaur dashboard session `stopped`.
10
+ - **Slash commands** — `doctor-syntaur` runs `syntaur doctor`; the rest point at the installed Tier-1
11
+ skill of the same name.
12
+
13
+ ## Install
14
+
15
+ `syntaur setup --target hermes` copies this directory into `~/.hermes/plugins/syntaur/` (or
16
+ `$HERMES_HOME/plugins/syntaur/`). Hermes auto-discovers `plugin.yaml` plugins there. `syntaur doctor`
17
+ reports Tier-3 install status.
18
+
19
+ ## Layout
20
+
21
+ ```
22
+ syntaur/
23
+ ├── plugin.yaml # manifest: name, version, provides_hooks
24
+ ├── __init__.py # register(ctx): registers the hooks + commands
25
+ └── boundary.py # pure write-boundary logic (unit-tested via python3)
26
+ ```
27
+
28
+ ## Caveats
29
+
30
+ - **Blocking is best-effort (version-dependent).** Hermes documents `pre_tool_call` primarily as a
31
+ fire-and-forget observer; some versions allow a return value to block. This plugin returns a deny
32
+ signal AND logs every violation to stderr + `~/.syntaur/tier3-violations.log`, so enforcement is
33
+ observable even if a given Hermes build ignores the block. Verify hard-block behavior against your
34
+ live Hermes runtime.
35
+ - Hooks never raise (the Hermes handler contract) — all bodies are wrapped in try/except.
36
+ - `boundary.py` is unit-tested for real (executed via `python3`) in Syntaur's
37
+ `src/__tests__/hermes-plugin.test.ts`.
@@ -0,0 +1,151 @@
1
+ """Syntaur Tier-3 enforcement plugin for Hermes Agent.
2
+
3
+ Registers two lifecycle hooks via `register(ctx)`:
4
+ - pre_tool_call : block (best-effort) + log writes outside the assignment boundary
5
+ - on_session_end: mark the Syntaur dashboard session "stopped"
6
+
7
+ plus the Syntaur slash commands. Stdlib only; never raises from a hook (the Hermes
8
+ handler contract). See README.md for the blocking-is-best-effort caveat.
9
+ """
10
+
11
+ import json
12
+ import os
13
+ import subprocess
14
+ import sys
15
+ import urllib.request
16
+
17
+ from . import boundary
18
+
19
+ # Hermes write tools use the snake_case dialect (read_file / patch / terminal).
20
+ WRITE_TOOLS = {"patch", "write_file", "edit_file", "create_file", "apply_patch"}
21
+ PATH_KEYS = ("file_path", "path", "filename", "target_file")
22
+
23
+ # Slash commands match the bare CC/Codex names. Only `doctor-syntaur` shells out;
24
+ # the rest point the agent at the installed Tier-1 skill of the same name.
25
+ CORE_COMMANDS = [
26
+ {"name": "doctor-syntaur", "description": "Run `syntaur doctor` diagnostics", "kind": "passthrough", "argv": ["doctor"]},
27
+ {"name": "grab-assignment", "description": "Claim a Syntaur assignment into this session", "kind": "guidance", "skill": "grab-assignment"},
28
+ {"name": "log-progress", "description": "Append a progress entry to the active assignment", "kind": "guidance", "skill": "log-progress"},
29
+ {"name": "complete-assignment", "description": "Write a handoff and complete the assignment", "kind": "guidance", "skill": "complete-assignment"},
30
+ {"name": "save-session-summary", "description": "Save a session continuity summary", "kind": "guidance", "skill": "save-session-summary"},
31
+ {"name": "resume-session", "description": "Re-orient on the active assignment", "kind": "guidance", "skill": "resume-session"},
32
+ {"name": "set-workspace", "description": "Set workspace fields on the active assignment", "kind": "guidance", "skill": "set-workspace"},
33
+ {"name": "track-session", "description": "Register this session in the Syntaur dashboard", "kind": "guidance", "skill": "track-session"},
34
+ ]
35
+
36
+
37
+ def _extract_write_path(tool_name, args):
38
+ if not isinstance(tool_name, str) or tool_name.lower() not in WRITE_TOOLS:
39
+ return None
40
+ if not isinstance(args, dict):
41
+ return None
42
+ for k in PATH_KEYS:
43
+ v = args.get(k)
44
+ if isinstance(v, str) and v:
45
+ return v
46
+ return None
47
+
48
+
49
+ def _dashboard_port():
50
+ env = os.environ.get("SYNTAUR_DASHBOARD_PORT")
51
+ if env:
52
+ return env
53
+ try:
54
+ with open(os.path.join(os.path.expanduser("~"), ".syntaur", "dashboard-port"), "r") as fh:
55
+ return fh.read().strip() or "4800"
56
+ except Exception:
57
+ return "4800"
58
+
59
+
60
+ def _log_violation(reason):
61
+ try:
62
+ sys.stderr.write("[syntaur] " + reason + "\n")
63
+ except Exception:
64
+ pass
65
+ try:
66
+ log = os.path.join(os.path.expanduser("~"), ".syntaur", "tier3-violations.log")
67
+ os.makedirs(os.path.dirname(log), exist_ok=True)
68
+ with open(log, "a", encoding="utf-8") as fh:
69
+ fh.write(reason + "\n")
70
+ except Exception:
71
+ pass
72
+
73
+
74
+ def _on_pre_tool_call(tool_name=None, args=None, task_id=None, **kwargs):
75
+ """Block (best-effort) + log writes outside the assignment boundary."""
76
+ try:
77
+ path = _extract_write_path(tool_name, args)
78
+ if not path:
79
+ return None
80
+ cwd = os.getcwd()
81
+ ctx = boundary.load_context(cwd)
82
+ if not ctx:
83
+ return None
84
+ abs_path = path if os.path.isabs(path) else os.path.join(cwd, path)
85
+ context_file = os.path.join(cwd, ".syntaur", "context.json")
86
+ allowed, reason = boundary.is_write_allowed(abs_path, ctx, context_file)
87
+ if not allowed:
88
+ _log_violation(reason)
89
+ # Hermes' pre_tool_call blocking contract is version-dependent; return a
90
+ # deny signal as a best-effort block. If ignored, the violation is logged.
91
+ return {"allow": False, "reason": reason}
92
+ return None
93
+ except Exception:
94
+ return None # never raise from a hook
95
+
96
+
97
+ def _on_session_end(session_id=None, completed=None, interrupted=None, model=None, platform=None, **kwargs):
98
+ """Mark the dashboard session stopped (best-effort)."""
99
+ try:
100
+ ctx = boundary.load_context(os.getcwd()) or {}
101
+ sid = ctx.get("sessionId") or session_id
102
+ if not sid:
103
+ return None
104
+ body = {"status": "stopped"}
105
+ if ctx.get("projectSlug"):
106
+ body["projectSlug"] = ctx["projectSlug"]
107
+ url = "http://127.0.0.1:%s/api/agent-sessions/%s/status" % (_dashboard_port(), sid)
108
+ req = urllib.request.Request(
109
+ url,
110
+ data=json.dumps(body).encode("utf-8"),
111
+ headers={"Content-Type": "application/json"},
112
+ method="PATCH",
113
+ )
114
+ try:
115
+ urllib.request.urlopen(req, timeout=3)
116
+ except Exception:
117
+ pass
118
+ return None
119
+ except Exception:
120
+ return None
121
+
122
+
123
+ def _make_command_handler(cmd):
124
+ def handler(*args, **kwargs):
125
+ if cmd["kind"] == "passthrough":
126
+ try:
127
+ out = subprocess.run(["syntaur"] + cmd["argv"], capture_output=True, text=True)
128
+ return (out.stdout or "") + (out.stderr or "")
129
+ except Exception as exc:
130
+ return "Failed to run syntaur %s: %s" % (" ".join(cmd["argv"]), exc)
131
+ return (
132
+ 'Follow the Syntaur "%s" skill (installed via skills). It derives the active '
133
+ "assignment/session from .syntaur/context.json." % cmd["skill"]
134
+ )
135
+
136
+ return handler
137
+
138
+
139
+ def register(ctx):
140
+ """Wire Syntaur's hooks + commands into Hermes. Called once at startup."""
141
+ ctx.register_hook("pre_tool_call", _on_pre_tool_call)
142
+ ctx.register_hook("on_session_end", _on_session_end)
143
+
144
+ # Command registration is best-effort across Hermes versions.
145
+ reg = getattr(ctx, "register_command", None)
146
+ if callable(reg):
147
+ for cmd in CORE_COMMANDS:
148
+ try:
149
+ reg(cmd["name"], _make_command_handler(cmd), description=cmd["description"])
150
+ except Exception:
151
+ pass
@@ -0,0 +1,99 @@
1
+ """Pure Syntaur write-boundary logic for the Hermes plugin.
2
+
3
+ Mirrors platforms/claude-code/hooks/enforce-boundaries.sh exactly. Kept dependency-free
4
+ (stdlib only) and side-effect-free so it can be unit-tested directly via `python3 -c`
5
+ from Syntaur's vitest suite.
6
+ """
7
+
8
+ import json
9
+ import os
10
+
11
+
12
+ def _expand_home(p):
13
+ if p == "~":
14
+ return os.path.expanduser("~")
15
+ if p.startswith("~/"):
16
+ return os.path.join(os.path.expanduser("~"), p[2:])
17
+ return p
18
+
19
+
20
+ def load_context(cwd):
21
+ """Read <cwd>/.syntaur/context.json; return a dict or None (fail-open)."""
22
+ path = os.path.join(cwd, ".syntaur", "context.json")
23
+ try:
24
+ with open(path, "r", encoding="utf-8") as fh:
25
+ data = json.load(fh)
26
+ except Exception:
27
+ return None
28
+ if not isinstance(data, dict):
29
+ return None
30
+
31
+ def s(key):
32
+ v = data.get(key)
33
+ return v if isinstance(v, str) and v else None
34
+
35
+ def home(key):
36
+ v = s(key)
37
+ return _expand_home(v) if v else None
38
+
39
+ return {
40
+ "assignmentDir": home("assignmentDir"),
41
+ "projectDir": home("projectDir"),
42
+ "workspaceRoot": home("workspaceRoot"),
43
+ "sessionId": s("sessionId"),
44
+ "projectSlug": s("projectSlug"),
45
+ }
46
+
47
+
48
+ def _norm(p):
49
+ return os.path.normpath(os.path.abspath(p))
50
+
51
+
52
+ def _is_under(child, parent):
53
+ """True if `child` is STRICTLY under `parent` (matches the bash "$X"/* test)."""
54
+ if not parent:
55
+ return False
56
+ c = _norm(child)
57
+ p = _norm(parent)
58
+ if c == p:
59
+ return False
60
+ return c.startswith(p + os.sep)
61
+
62
+
63
+ def is_write_allowed(abs_file_path, ctx, context_file_abs=None):
64
+ """Return (allowed: bool, reason: str|None) for a write to abs_file_path.
65
+
66
+ Allowed when the path is under the assignment dir, under project
67
+ resources/memories (excluding derived `_*` files), equal to the context file,
68
+ or under the workspace root. Otherwise blocked.
69
+ """
70
+ # Parity with the bash hook (enforce-boundaries.sh:69): enforcement requires
71
+ # BOTH assignmentDir and projectDir. If either is missing (standalone / old /
72
+ # partially-written context), fail OPEN — allow everything.
73
+ if not ctx.get("assignmentDir") or not ctx.get("projectDir"):
74
+ return True, None
75
+
76
+ f = _norm(abs_file_path)
77
+
78
+ if ctx.get("assignmentDir") and _is_under(f, ctx["assignmentDir"]):
79
+ return True, None
80
+
81
+ project_dir = ctx.get("projectDir")
82
+ if project_dir:
83
+ for sub in ("resources", "memories"):
84
+ d = os.path.join(project_dir, sub)
85
+ if _is_under(f, d) and not os.path.basename(f).startswith("_"):
86
+ return True, None
87
+
88
+ if context_file_abs and f == _norm(context_file_abs):
89
+ return True, None
90
+
91
+ if ctx.get("workspaceRoot") and _is_under(f, ctx["workspaceRoot"]):
92
+ return True, None
93
+
94
+ reason = (
95
+ "Syntaur write boundary violation: cannot write to '%s'. Allowed: assignment dir "
96
+ "(%s), project resources/memories, workspace (%s)."
97
+ % (f, ctx.get("assignmentDir") or "n/a", ctx.get("workspaceRoot") or "n/a")
98
+ )
99
+ return False, reason
@@ -0,0 +1,6 @@
1
+ name: syntaur
2
+ version: 0.1.0
3
+ description: Syntaur write-boundary enforcement + session cleanup for Hermes Agent
4
+ provides_hooks:
5
+ - pre_tool_call
6
+ - on_session_end
@@ -0,0 +1,34 @@
1
+ # Syntaur extension for pi / OpenClaw (Tier-3)
2
+
3
+ A self-contained pi-coding-agent extension that brings Syntaur's Tier-3 enforcement parity (the same
4
+ behavior the Claude Code / Codex plugins have) to **pi** and **OpenClaw** (which runs on
5
+ pi-coding-agent):
6
+
7
+ - **Write-boundary enforcement** — a `tool_call` handler blocks edits/writes outside the active
8
+ assignment's boundaries (assignment dir, project `resources/`+`memories/` excluding derived `_*`
9
+ files, and the workspace root), mirroring `platforms/claude-code/hooks/enforce-boundaries.sh`.
10
+ - **Session cleanup** — a `session_shutdown` handler marks the dashboard session `stopped`.
11
+ - **Slash commands** — `doctor-syntaur` runs `syntaur doctor`; the rest (`grab-assignment`,
12
+ `log-progress`, `complete-assignment`, `save-session-summary`, `resume-session`, `set-workspace`,
13
+ `track-session`) point the agent at the installed Tier-1 skill of the same name.
14
+
15
+ ## Install
16
+
17
+ `syntaur setup --target pi` (or `--target openclaw`) copies this directory into the agent's extension
18
+ dir:
19
+
20
+ - pi → `~/.pi/agent/extensions/syntaur/`
21
+ - OpenClaw → `~/.openclaw/extensions/syntaur/`
22
+
23
+ pi auto-discovers `*/index.ts` extensions there (loaded via jiti). `syntaur doctor` reports Tier-3
24
+ install status.
25
+
26
+ ## Notes & caveats
27
+
28
+ - **OpenClaw assumption:** per the cross-agent design memo, OpenClaw runs on `pi-coding-agent` and
29
+ loads the same extension format, so it reuses this exact source (only the install dir differs). If a
30
+ given OpenClaw build diverges to its own plugin system, only the install target needs repointing.
31
+ - The boundary decision logic (`isWriteAllowed`, `extractWritePath`, `loadContext`) is exported and
32
+ unit-tested in Syntaur's `src/__tests__/pi-extension.test.ts`.
33
+ - Fail-open: if there is no `.syntaur/context.json` or the tool call isn't a write, the extension
34
+ allows it — exactly like the bash hooks.
@@ -0,0 +1,265 @@
1
+ // Syntaur Tier-3 enforcement extension for pi-coding-agent (and OpenClaw, which
2
+ // runs on pi). Mirrors the Claude Code / Codex bash hooks:
3
+ // - write-boundary enforcement (pi `tool_call` event → { block, reason })
4
+ // - session cleanup (pi `session_shutdown` event → mark session stopped)
5
+ // - Syntaur slash commands (pi `registerCommand`)
6
+ //
7
+ // SELF-CONTAINED: this file is shipped verbatim into the user's pi extensions dir
8
+ // (~/.pi/agent/extensions/syntaur/ or ~/.openclaw/extensions/syntaur/) and loaded by
9
+ // pi via jiti. It must NOT import from Syntaur's `src/`. The pure functions are
10
+ // exported so Syntaur's vitest suite can verify the boundary logic directly.
11
+ //
12
+ // pi extension API (researched): `export default (pi) => { pi.on(event, handler);
13
+ // pi.registerCommand(name, { description, handler }) }`. The `tool_call` handler
14
+ // returns `{ block: true, reason }` to DENY a tool call; any other return allows.
15
+
16
+ import { readFileSync } from 'node:fs';
17
+ import { homedir } from 'node:os';
18
+ import { isAbsolute, resolve, sep } from 'node:path';
19
+
20
+ export interface SyntaurContext {
21
+ assignmentDir?: string;
22
+ projectDir?: string;
23
+ workspaceRoot?: string;
24
+ sessionId?: string;
25
+ projectSlug?: string;
26
+ }
27
+
28
+ /** Expand a leading `~` to the home dir (the only expansion the bash hooks do). */
29
+ function expandHome(p: string): string {
30
+ if (p === '~') return homedir();
31
+ if (p.startsWith('~/')) return resolve(homedir(), p.slice(2));
32
+ return p;
33
+ }
34
+
35
+ /** Read `<cwd>/.syntaur/context.json`; null when absent/unparseable (fail-open). */
36
+ export function loadContext(cwd: string): SyntaurContext | null {
37
+ const file = resolve(cwd, '.syntaur', 'context.json');
38
+ let raw: string;
39
+ try {
40
+ raw = readFileSync(file, 'utf-8');
41
+ } catch {
42
+ return null;
43
+ }
44
+ let data: Record<string, unknown>;
45
+ try {
46
+ data = JSON.parse(raw) as Record<string, unknown>;
47
+ } catch {
48
+ return null;
49
+ }
50
+ const str = (v: unknown): string | undefined =>
51
+ typeof v === 'string' && v.length > 0 ? v : undefined;
52
+ const assignmentDir = str(data.assignmentDir);
53
+ const projectDir = str(data.projectDir);
54
+ const workspaceRoot = str(data.workspaceRoot);
55
+ return {
56
+ assignmentDir: assignmentDir ? expandHome(assignmentDir) : undefined,
57
+ projectDir: projectDir ? expandHome(projectDir) : undefined,
58
+ workspaceRoot: workspaceRoot ? expandHome(workspaceRoot) : undefined,
59
+ sessionId: str(data.sessionId),
60
+ projectSlug: str(data.projectSlug),
61
+ };
62
+ }
63
+
64
+ /** True if `child` is strictly under `parent` (mirrors the bash `"$X"/*` test). */
65
+ function isUnder(child: string, parent: string): boolean {
66
+ if (!parent) return false;
67
+ const c = resolve(child);
68
+ const p = resolve(parent);
69
+ return c.startsWith(p.endsWith(sep) ? p : p + sep);
70
+ }
71
+
72
+ function basename(p: string): string {
73
+ const norm = resolve(p);
74
+ const idx = norm.lastIndexOf(sep);
75
+ return idx >= 0 ? norm.slice(idx + 1) : norm;
76
+ }
77
+
78
+ /**
79
+ * Decide whether a write to `absFilePath` is allowed under the active assignment.
80
+ * Mirrors `platforms/claude-code/hooks/enforce-boundaries.sh` exactly:
81
+ * - allow under assignmentDir
82
+ * - allow under projectDir/resources/ and projectDir/memories/ EXCEPT derived `_*` files
83
+ * - allow the `.syntaur/context.json` file itself (caller passes cwd-resolved path)
84
+ * - allow under workspaceRoot (if set)
85
+ * - otherwise block
86
+ */
87
+ export function isWriteAllowed(
88
+ absFilePath: string,
89
+ ctx: SyntaurContext,
90
+ contextFileAbs?: string,
91
+ ): { allowed: boolean; reason?: string } {
92
+ // Parity with the bash hook (enforce-boundaries.sh:69): enforcement requires
93
+ // BOTH assignmentDir and projectDir. If either is missing (standalone / old /
94
+ // partially-written context), fail OPEN — allow everything.
95
+ if (!ctx.assignmentDir || !ctx.projectDir) return { allowed: true };
96
+
97
+ const file = resolve(absFilePath);
98
+
99
+ if (ctx.assignmentDir && isUnder(file, ctx.assignmentDir)) return { allowed: true };
100
+
101
+ if (ctx.projectDir) {
102
+ const resourcesDir = resolve(ctx.projectDir, 'resources');
103
+ const memoriesDir = resolve(ctx.projectDir, 'memories');
104
+ for (const dir of [resourcesDir, memoriesDir]) {
105
+ if (isUnder(file, dir) && !basename(file).startsWith('_')) return { allowed: true };
106
+ }
107
+ }
108
+
109
+ if (contextFileAbs && file === resolve(contextFileAbs)) return { allowed: true };
110
+
111
+ if (ctx.workspaceRoot && isUnder(file, ctx.workspaceRoot)) return { allowed: true };
112
+
113
+ const reason =
114
+ `Syntaur write boundary violation: cannot write to '${file}'. Allowed: assignment dir ` +
115
+ `(${ctx.assignmentDir ?? 'n/a'}), project resources/memories, workspace ` +
116
+ `(${ctx.workspaceRoot ?? 'n/a'}).`;
117
+ return { allowed: false, reason };
118
+ }
119
+
120
+ /** pi write-ish tool names (lowercase dialect), matched case-insensitively. */
121
+ export const WRITE_TOOLS: ReadonlySet<string> = new Set([
122
+ 'edit',
123
+ 'write',
124
+ 'multi_edit',
125
+ 'multiedit',
126
+ 'create',
127
+ 'create_file',
128
+ 'str_replace',
129
+ 'str_replace_editor',
130
+ 'apply_patch',
131
+ ]);
132
+
133
+ const PATH_KEYS = ['file_path', 'path', 'filePath', 'filename', 'target_file'];
134
+
135
+ /** Extract the write target path from a tool call; null if not a write / no path. */
136
+ export function extractWritePath(toolName: unknown, input: unknown): string | null {
137
+ if (typeof toolName !== 'string') return null;
138
+ if (!WRITE_TOOLS.has(toolName.toLowerCase())) return null;
139
+ if (typeof input !== 'object' || input === null) return null;
140
+ const obj = input as Record<string, unknown>;
141
+ for (const k of PATH_KEYS) {
142
+ const v = obj[k];
143
+ if (typeof v === 'string' && v.length > 0) return v;
144
+ }
145
+ return null;
146
+ }
147
+
148
+ function resolveAbs(p: string, cwd: string): string {
149
+ return isAbsolute(p) ? resolve(p) : resolve(cwd, p);
150
+ }
151
+
152
+ export interface CoreCommand {
153
+ name: string;
154
+ description: string;
155
+ kind: 'passthrough' | 'guidance';
156
+ /** For `passthrough`: argv passed to the `syntaur` CLI. */
157
+ argv?: string[];
158
+ /** For `guidance`: the installed Tier-1 skill the agent should follow. */
159
+ skill?: string;
160
+ }
161
+
162
+ // Slash commands match the existing CC/Codex bare command names for parity. Only
163
+ // `doctor-syntaur` shells out (no required args); the rest point the agent at the
164
+ // installed Tier-1 skill, which derives assignment/session from .syntaur/context.json.
165
+ export const CORE_COMMANDS: CoreCommand[] = [
166
+ { name: 'doctor-syntaur', description: 'Run `syntaur doctor` diagnostics', kind: 'passthrough', argv: ['doctor'] },
167
+ { name: 'grab-assignment', description: 'Claim a Syntaur assignment into this session', kind: 'guidance', skill: 'grab-assignment' },
168
+ { name: 'log-progress', description: 'Append a progress entry to the active assignment', kind: 'guidance', skill: 'log-progress' },
169
+ { name: 'complete-assignment', description: 'Write a handoff and complete the assignment', kind: 'guidance', skill: 'complete-assignment' },
170
+ { name: 'save-session-summary', description: 'Save a session continuity summary', kind: 'guidance', skill: 'save-session-summary' },
171
+ { name: 'resume-session', description: 'Re-orient on the active assignment', kind: 'guidance', skill: 'resume-session' },
172
+ { name: 'set-workspace', description: 'Set workspace fields on the active assignment', kind: 'guidance', skill: 'set-workspace' },
173
+ { name: 'track-session', description: 'Register this session in the Syntaur dashboard', kind: 'guidance', skill: 'track-session' },
174
+ ];
175
+
176
+ function dashboardPort(): string {
177
+ const env = process.env.SYNTAUR_DASHBOARD_PORT;
178
+ if (env && env.length > 0) return env;
179
+ try {
180
+ return readFileSync(resolve(homedir(), '.syntaur', 'dashboard-port'), 'utf-8').trim() || '4800';
181
+ } catch {
182
+ return '4800';
183
+ }
184
+ }
185
+
186
+ /** Mark the dashboard session stopped (best-effort, swallow every error). */
187
+ export async function markSessionStopped(ctx: SyntaurContext | null): Promise<void> {
188
+ if (!ctx?.sessionId) return;
189
+ const body = JSON.stringify(
190
+ ctx.projectSlug ? { status: 'stopped', projectSlug: ctx.projectSlug } : { status: 'stopped' },
191
+ );
192
+ try {
193
+ await fetch(`http://127.0.0.1:${dashboardPort()}/api/agent-sessions/${ctx.sessionId}/status`, {
194
+ method: 'PATCH',
195
+ headers: { 'Content-Type': 'application/json' },
196
+ body,
197
+ signal: AbortSignal.timeout(3000),
198
+ });
199
+ } catch {
200
+ /* dashboard not running / unreachable — ignore */
201
+ }
202
+ }
203
+
204
+ function notify(ctx: unknown, message: string, level: 'info' | 'error' = 'info'): void {
205
+ const ui = (ctx as { ui?: { notify?: (m: string, l?: string) => void } } | undefined)?.ui;
206
+ if (ui?.notify) {
207
+ try {
208
+ ui.notify(message, level);
209
+ return;
210
+ } catch {
211
+ /* fall through to console */
212
+ }
213
+ }
214
+ // eslint-disable-next-line no-console
215
+ console.log(message);
216
+ }
217
+
218
+ /** pi/OpenClaw extension entry point. */
219
+ export default function activate(pi: {
220
+ on: (event: string, handler: (event: unknown, ctx?: unknown) => unknown) => void;
221
+ registerCommand: (
222
+ name: string,
223
+ spec: { description: string; handler: (args: string, ctx: unknown) => unknown },
224
+ ) => void;
225
+ }): void {
226
+ // --- write-boundary enforcement ---
227
+ pi.on('tool_call', (event: unknown) => {
228
+ const e = (event ?? {}) as { toolName?: unknown; input?: unknown };
229
+ const path = extractWritePath(e.toolName, e.input);
230
+ if (!path) return; // not a write → allow
231
+ const cwd = process.cwd();
232
+ const ctx = loadContext(cwd);
233
+ if (!ctx) return; // no active assignment → allow
234
+ const abs = resolveAbs(path, cwd);
235
+ const contextFileAbs = resolve(cwd, '.syntaur', 'context.json');
236
+ const { allowed, reason } = isWriteAllowed(abs, ctx, contextFileAbs);
237
+ if (!allowed) return { block: true, reason };
238
+ return undefined;
239
+ });
240
+
241
+ // --- session cleanup ---
242
+ pi.on('session_shutdown', async () => {
243
+ await markSessionStopped(loadContext(process.cwd()));
244
+ });
245
+
246
+ // --- slash commands ---
247
+ for (const cmd of CORE_COMMANDS) {
248
+ pi.registerCommand(cmd.name, {
249
+ description: cmd.description,
250
+ handler: async (_args: string, ctx: unknown) => {
251
+ if (cmd.kind === 'passthrough' && cmd.argv) {
252
+ const { spawnSync } = await import('node:child_process');
253
+ const r = spawnSync('syntaur', cmd.argv, { encoding: 'utf-8' });
254
+ notify(ctx, (r.stdout || '') + (r.stderr || '') || `ran: syntaur ${cmd.argv.join(' ')}`);
255
+ return;
256
+ }
257
+ notify(
258
+ ctx,
259
+ `Follow the Syntaur "${cmd.skill}" skill (installed via skills). It derives the active ` +
260
+ `assignment/session from .syntaur/context.json — run its steps to ${cmd.description.toLowerCase()}.`,
261
+ );
262
+ },
263
+ });
264
+ }
265
+ }