Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
156 changes: 156 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,162 @@ or invoke the Linux binary via `wsl.exe`:

---

## Connecting to IRIS

### Native IRIS on Windows or Linux (no Docker)

Add a `.iris-agentic-dev.toml` file to your project root:

```toml
host = "localhost"
web_port = 80 # IIS default for IRIS 2024.1+; use 52773 for pre-2024.1
namespace = "USER"
username = "_SYSTEM"
password = "SYS"
```

#### Port reference

| IRIS version | Web server | Default port |
|---|---|---|
| 2024.1+ on Windows | IIS | 80 |
| 2024.1+ on Linux | Apache | 80 |
| Pre-2024.1 (any OS) | Private Web Server (PWS) | 52773 |

#### Windows IIS: `/api` web application required

This is the most common failure on Windows. IIS needs an explicit `/api` web application mapped to the IRIS Web Gateway module. Without it, `/api/atelier` returns 404 — even when the Management Portal loads correctly.

**To fix:**

1. Open **IIS Manager** → expand your server → **Sites** → **Default Web Site**
2. Right-click → **Add Application**. Set alias: `api`, physical path: `C:\InterSystems\IRIS\CSP\bin` (adjust to your install path)
3. Add a wildcard script handler mapping: executable = `CSPms.dll`, no verb restriction
4. Verify `CSP.ini` contains an `[APP_PATH:/api]` section

See the [`iris-windows-iis-setup` skill](./light-skills/skills/iris-windows-iis-setup/SKILL.md) for full step-by-step instructions with verification commands.

**`localhost` vs `127.0.0.1`**: On some older Web Gateway builds, using `localhost` causes a brief connection error before each request. If you see connection delays, change the config to `host = "127.0.0.1"`.

### Docker (community image)

Run `iris-agentic-dev init` in your project directory — it detects any running IRIS containers and writes `.iris-agentic-dev.toml` automatically:

```bash
iris-agentic-dev init
```

Or configure manually:

```toml
container = "myapp-iris"
namespace = "MYAPP"
```

### Docker (enterprise image)

Enterprise IRIS images (`intersystems/iris`, `intersystems/irishealth`) ship without a built-in web server. Run the ISC Web Gateway container alongside IRIS:

```yaml
services:
iris:
image: containers.intersystems.com/intersystems/iris:2026.1
ports: ["4972:1972"]
webgateway:
image: containers.intersystems.com/intersystems/webgateway:2026.1
ports: ["52773:80"]
entrypoint: ["/bin/sh", "/init.sh"]
volumes: ["./webgateway-init.sh:/init.sh:ro"]
```

See the [`iris-vscode-objectscript` skill](./light-skills/skills/iris-vscode-objectscript/SKILL.md) for a working `webgateway-init.sh`.

### VS Code Server Manager (zero-config)

If the [InterSystems Server Manager](https://marketplace.visualstudio.com/items?itemName=intersystems-community.servermanager) extension is installed, iris-agentic-dev reads your server list from VS Code's `settings.json` and resolves credentials from the OS keychain automatically — no `.iris-agentic-dev.toml` needed.

**Single server configured:** auto-connects, no extra setup.

**Multiple servers configured:** set `IRIS_SERVER_NAME` to the map key from `intersystems.servers`:

```bash
export IRIS_SERVER_NAME=dev-local
```

Credentials are stored under keychain service `"intersystems-server-credentials"` — the auth provider ID used by Server Manager in all VS Code-compatible forks (Cursor, Windsurf, VS Code Insiders). If a credential is missing, iris-agentic-dev fails fast with a message directing you to reconnect in VS Code (right-click the server → **Reconnect**) rather than silently falling through to other discovery sources.

Use `check_config` to see which servers were detected and whether credentials resolved:

```json
{
"server_manager": {
"available": true,
"servers": [
{ "name": "dev-local", "active": true, "credential_status": "resolved" }
]
}
}
```

### Per-connection policy (fleet / operate mode)

Add `[policy.<server-name>]` blocks to `.iris-agentic-dev.toml` to restrict which tool categories are permitted on a given Server Manager server:

```toml
[policy.prod]
allow = ["query", "search", "docs"]
```

Blocked calls return `error_code: "POLICY_GATE"` with the list of allowed categories. Omit the block entirely to permit everything. Available categories: `compile`, `execute`, `query`, `search`, `docs`, `source_control`, `debug`, `admin`, `skill`, `kb`.

For multi-instance fleet workflows (`mode = "operate"`), see the [fleet roles spec](./specs/003-workspace-config/) for the full `[instance.*]` config format and role-gate behavior.

### Connection discovery order

iris-agentic-dev resolves the IRIS connection in this order — first match wins:

1. CLI flags (`--host`, `--web-port`, `--scheme`)
2. `.iris-agentic-dev.toml` in the workspace root
3. Environment variables (`IRIS_HOST`, etc.)
4. VS Code `settings.json` (`objectscript.conn` / `intersystems.servers`)
5. VS Code Server Manager keychain (`intersystems.servers` + OS keychain credential)
6. Running Docker containers (scored by workspace name similarity)
7. Localhost port scan (52773, 41773, 51773, 8080)

### Environment variables

| Variable | Default | Description |
|----------|---------|-------------|
| `IRIS_HOST` | `localhost` | IRIS web gateway hostname |
| `IRIS_WEB_PORT` | `52773` | Web gateway port |
| `IRIS_SCHEME` | `http` | `http` or `https` |
| `IRIS_WEB_PREFIX` | *(empty)* | URL path prefix for non-root gateway installs |
| `IRIS_USERNAME` | `_SYSTEM` | IRIS username |
| `IRIS_PASSWORD` | `SYS` | IRIS password |
| `IRIS_SERVICE_USERNAME` | *(empty)* | Restricted service account for arbitrary-execution tools (see below) |
| `IRIS_SERVICE_PASSWORD` | *(empty)* | Password for `IRIS_SERVICE_USERNAME` |
| `IRIS_NAMESPACE` | `USER` | Default namespace |
| `IRIS_CONTAINER` | *(empty)* | Docker container name — required for Docker-dependent tools |
| `IRIS_SERVER_NAME` | *(empty)* | Server Manager server name when multiple are configured |
| `OBJECTSCRIPT_WORKSPACE` | `$PWD` | Workspace root for `.iris-agentic-dev.toml` lookup |

### Privilege separation for arbitrary execution

`iris_execute`, `iris_execute_method`, `iris_query` (`mode="write"`), and `iris_global`
(`set`/`kill`) can run arbitrary ObjectScript/SQL. Under a `%All` account these can edit class
and routine code — even by indirection (`$classmethod`, `$method("%Sa"_"ve")`, `xecute`) —
bypassing the SCM lock and the `CODE_EDIT_BLOCKED` string filter, since no static text filter
can be exhaustive against a fully-privileged identity.

Set `IRIS_SERVICE_USERNAME` / `IRIS_SERVICE_PASSWORD` to a **least-privilege** IRIS account
(no `%Development` resource, code database mounted read-only). Those four tools then authenticate
as that account, so code edits fail with `<PROTECT>` at the IRIS privilege layer regardless of
indirection. Code-writing tools (`iris_document` put, `iris_source_control`, `iris_compile`)
deliberately keep using the primary `IRIS_USERNAME`, so SCM checkouts and audit stay attributed
to the real user. When unset, all tools use the primary connection (unchanged behaviour).

---

## Skills — improve AI output for ObjectScript

Skills are concise instruction files that teach your AI assistant ObjectScript patterns and
Expand Down
142 changes: 142 additions & 0 deletions crates/iris-agentic-dev-core/src/tools/doc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -804,6 +804,59 @@ pub fn apply_delete_lines(lines: &[String], start: i64, end: i64) -> (Vec<String
(out, removed, actual_start, actual_end)
}

/// Number of unchanged context lines shown around a change in the rendered diff.
const DIFF_CONTEXT: usize = 3;

/// Build a git-style unified diff for a single contiguous edit, ready to drop into a
/// ```diff fenced block. `before`/`after` are the full line vectors; the changed region is
/// `before[del_start..del_start+del_len]` replaced by `add` (0-based `del_start`). Because
/// surgical edits (insert/delete_lines) only ever touch one contiguous span, we can emit an
/// exact hunk without running a diff algorithm — the caller already knows what changed.
///
/// Returns the diff body (no ```diff fence — the display layer adds that): a single
/// `@@ -l,s +l,s @@` hunk header, then ` ` context / `-` removed / `+` added lines.
///
/// Context lines (leading and trailing) are identical on both sides of the change, so they
/// are taken from `before`; only `before`, the removed span, and `add` are needed.
fn unified_diff(before: &[String], del_start: usize, del_len: usize, add: &[String]) -> String {
let ctx_start = del_start.saturating_sub(DIFF_CONTEXT);
// Leading context precedes the change on both sides identically.
let lead = &before[ctx_start..del_start];
// Trailing context follows the change; take it from `before` after the removed span.
let after_change = del_start + del_len;
let trail_end = (after_change + DIFF_CONTEXT).min(before.len());
let trail = &before[after_change..trail_end];

// 1-based hunk line numbers and spans (old side / new side).
let old_start = ctx_start + 1;
let old_count = lead.len() + del_len + trail.len();
let new_start = old_start; // context before the change is identical, so same first line
let new_count = lead.len() + add.len() + trail.len();

let mut out = String::new();
out.push_str(&format!(
"@@ -{},{} +{},{} @@\n",
old_start, old_count, new_start, new_count
));
for l in lead {
out.push_str(&format!(" {l}\n"));
}
for l in &before[del_start..after_change] {
out.push_str(&format!("-{l}\n"));
}
for l in add {
out.push_str(&format!("+{l}\n"));
}
for l in trail {
out.push_str(&format!(" {l}\n"));
}
// Drop the trailing newline so the fenced block has no blank last line.
if out.ends_with('\n') {
out.pop();
}
out
}

/// Compare `expected` (multi-line) against `actual` lines, ignoring trailing
/// whitespace on each line and a single trailing blank line on either side.
/// Returns None if they match, or Some((line_offset, expected_line, actual_line))
Expand Down Expand Up @@ -939,6 +992,9 @@ async fn handle_insert(
let block: Vec<String> = block_src.lines().map(|s| s.to_string()).collect();
let (new_lines, actual_at) = apply_insert(&existing, at, &block);
let new_content = new_lines.join("\n");
// Build the diff before the write, while we still hold before/after in memory (no extra
// IRIS round-trip). An insert removes nothing at (actual_at - 1) and adds `block` there.
let diff = unified_diff(&existing, (actual_at - 1) as usize, 0, &block);

let result = write_with_scm(
iris,
Expand All @@ -960,6 +1016,7 @@ async fn handle_insert(
"edit": "insert",
"inserted_at": actual_at,
"lines_added": block.len(),
"diff": diff,
}),
)
.await)
Expand Down Expand Up @@ -1038,6 +1095,14 @@ async fn handle_delete_lines(
);
}
let new_content = new_lines.join("\n");
// Build the diff before the write, while we still hold before/after in memory (no extra
// IRIS round-trip). delete_lines removes [actual_start, actual_end] and adds nothing.
let diff = unified_diff(
&existing,
(actual_start - 1) as usize,
removed as usize,
&[],
);

let result = write_with_scm(
iris,
Expand All @@ -1060,6 +1125,7 @@ async fn handle_delete_lines(
"deleted_start": actual_start,
"deleted_end": actual_end,
"lines_removed": removed,
"diff": diff,
}),
)
.await)
Expand Down Expand Up @@ -2361,6 +2427,82 @@ mod tests {
assert_eq!(removed, 3);
}

// ── unified_diff (rendered markdown diff) ─────────────────────────────────
#[test]
fn test_unified_diff_insert_middle_has_context_and_plus_lines() {
// Insert "X" before line 3 (0-based index 2) of a-b-c-d-e.
let before = v(&["a", "b", "c", "d", "e"]);
let add = v(&["X"]);
let diff = unified_diff(&before, 2, 0, &add);
// Hunk header: 3 context before + 0 removed + 2 trailing = old span 5 from line 1;
// new span = 3 context + 1 added + 2 trailing... but leading context is capped at
// del_start (2 lines here: a,b). So old=4 (a,b + c,d trailing? no): verify structurally.
assert!(
diff.starts_with("@@ -1,"),
"hunk header starts at line 1: {diff}"
);
assert!(diff.contains("+X"), "added line is prefixed with +: {diff}");
// The inserted line is the only + line; everything else is context (space-prefixed).
assert_eq!(
diff.matches("\n+").count() + diff.starts_with('+') as usize,
1
);
// No removed lines on a pure insert.
assert!(
!diff.contains("\n-"),
"insert must have no removed lines: {diff}"
);
}

#[test]
fn test_unified_diff_delete_has_minus_lines_and_no_plus() {
// Delete lines 2..3 (b,c) from a-b-c-d-e → 0-based del_start=1, del_len=2.
let before = v(&["a", "b", "c", "d", "e"]);
let diff = unified_diff(&before, 1, 2, &[]);
assert!(diff.contains("-b"), "removed line b: {diff}");
assert!(diff.contains("-c"), "removed line c: {diff}");
assert!(
!diff.contains("\n+"),
"delete must have no added lines: {diff}"
);
// Context line a precedes the change.
assert!(diff.contains(" a"), "leading context present: {diff}");
}

#[test]
fn test_unified_diff_context_capped_at_document_bounds() {
// Change at the very start → no leading context available; header still starts at 1.
let before = v(&["a", "b", "c"]);
let diff = unified_diff(&before, 0, 1, &[]);
assert!(diff.starts_with("@@ -1,"), "header from line 1: {diff}");
assert!(diff.contains("-a"), "first line removed: {diff}");
// Trailing context (b,c) shown as space-prefixed, no panic on out-of-range.
assert!(
diff.contains(" b") && diff.contains(" c"),
"trailing context: {diff}"
);
}

#[test]
fn test_unified_diff_replace_shows_minus_then_plus() {
// Replace line 2 (b) with Y: del_start=1, del_len=1, add=[Y].
let before = v(&["a", "b", "c"]);
let diff = unified_diff(&before, 1, 1, &v(&["Y"]));
let minus = diff.find("-b").expect("removed b");
let plus = diff.find("+Y").expect("added Y");
assert!(minus < plus, "removed line comes before added line: {diff}");
}

#[test]
fn test_unified_diff_no_trailing_newline() {
let before = v(&["a", "b", "c"]);
let diff = unified_diff(&before, 1, 0, &v(&["X"]));
assert!(
!diff.ends_with('\n'),
"diff must not end with a newline: {diff:?}"
);
}

// ── diff_expected (stale-edit guard) ──────────────────────────────────────
#[test]
fn test_diff_expected_match() {
Expand Down
2 changes: 1 addition & 1 deletion crates/iris-agentic-dev-core/src/tools/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3477,7 +3477,7 @@ do ##class(%UnitTest.Manager).RunTest("{pattern}","{flags}","{token}")"#,
}

#[tool(
description = "Read/write/delete IRIS documents. mode: get (fetch source), put (write, auto SCM checkout), delete, head (existence), fragment (read lines start..end), compiled (read INT), list (glob `pattern`), insert (splice `content` before 1-based `line`; omit `line` to append), delete_lines (remove start..end). `name` is required for all single-document modes; `line`/`start`/`end` are integers. For insert with an explicit `line` and for delete_lines, pass `expected` (current text at the target lines) or the edit is refused with STALE_CONTENT. Edits return the re-numbered post-write `content` to chain from. Batch via `names`; SCM dialogs resume via elicitation_id/elicitation_answer."
description = "Read/write/delete IRIS documents. mode: get (fetch source), put (write, auto SCM checkout), delete, head (existence), fragment (read lines start..end), compiled (read INT), list (glob `pattern`), insert (splice `content` before 1-based `line`; omit `line` to append), delete_lines (remove start..end). `name` is required for all single-document modes; `line`/`start`/`end` are integers. For insert with an explicit `line` and for delete_lines, pass `expected` (current text at the target lines) or the edit is refused with STALE_CONTENT. Edits return the re-numbered post-write `content` to chain from, plus a `diff` field (git-style unified diff of the change) — render it to the user inside a ```diff fenced code block. Batch via `names`; SCM dialogs resume via elicitation_id/elicitation_answer."
)]
async fn iris_doc(
&self,
Expand Down
1 change: 1 addition & 0 deletions crates/iris-agentic-dev-core/src/tools/scm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -578,6 +578,7 @@ fn parse_checked_out_by(raw: &str) -> Option<(String, Option<String>)> {
static RE: OnceLock<regex::Regex> = OnceLock::new();
let re = RE.get_or_init(|| {
// "checked out by user 'xxx'" — timestamp captured only if the line isn't truncated.

regex::Regex::new(r"checked out by user '([^']+)'(?:.*?updated at ([0-9-]+ [0-9:]+))?")
.expect("static SCM checked-out regex is valid")
});
Expand Down
Loading