Skip to content
Merged
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
6 changes: 6 additions & 0 deletions .claude/skills/pr/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,12 @@ Refs: #127
- No `Co-Authored-By`, no "Generated with" line, no tool link: this repository is
public and commit-lint rejects all three.

## 4b. Read the diff as a reviewer

Check the new and touched code against `docs/agent/dev.md`, "Clean code:
what a reviewer checks". Fix what breaks a rule before opening the PR, or
say in the handover which rule it breaks and why.

## 5. Check

```bash
Expand Down
37 changes: 36 additions & 1 deletion docs/agent/dev.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ def run(args, stacklet, config):
```

Rules:
- Return a dict. The framework decides JSON vs pretty.
- Return a dict. With `--json` the framework prints it as JSON on stdout and sends whatever the command printed to stderr; without it, the command's own output is what people see. `--json` never reaches the command's arguments.
- On error, return `{"error": "..."}` - framework translates to non-zero exit.
- **Never bypass the CLI from another stacklet.** Use `ctx.stack.run_cli_command(<id>, <cmd>, ...)`.

Expand Down Expand Up @@ -223,6 +223,8 @@ make test-smoke # quick managed-rig e2e subset.
make test-e2e # full managed-rig e2e suite.
```

To try something on a running instance by hand (say something as Marge, read what the archivist answered, run `./stack` on the test Mac), use `tests/driver/driver`; see its README. It drives the stack through its own surfaces only.

Run the cheapest lane that proves what you changed. Every lane but
`test-unit` owns fixed container names and ports, so exactly one runs at a
time on a Mac: check nothing else is mid-run first. One name per lane, no
Expand Down Expand Up @@ -260,6 +262,39 @@ Testing rules:
- **Re-read the full error line before calling a failure a duplicate.** Check sender, target, specific IDs. Two errors that look similar at a glance often differ in the load-bearing field.
- **No backwards-compatibility shims** in pre-1.0 code. Change the code, update callers, ship.

### Clean code: what a reviewer checks

Structure a reader can follow without running the code, so a review reads the
change instead of reverse-engineering it. After Robert C. Martin's *Clean
Code*, trimmed to what this codebase needs.

1. **A function does one thing, at one level of abstraction.** The top level
reads like a table of contents. A description that needs "and then" is two
functions.
2. **Small.** A function fits on a screen (about 30 lines), a file holds one
topic (about 300 lines). Past that, splitting is the rule.
3. **Names say what, not how.** `answer_to(message)`, not
`select(messages, after=...)`. No abbreviations beyond the domain's own.
4. **No flag arguments that switch behaviour.** `as_json=True` deep in the
logic is two functions, or rendering that belongs at the edge.
5. **Return early instead of nesting.** At most two levels of indentation in
the logic.
6. **I/O at the edge, decisions in the middle.** Parsing, deciding and
formatting are pure functions; only the outer layer talks to ssh, Matrix,
a database or the terminal (principle 5 in AGENTS.md, made concrete).
7. **Name the magic.** A regex, a port, a timeout becomes a named constant
with a one-line reason.
8. **No dead code.** Delete instead of commenting out; a comment says why,
never what the code already says.

9. **A class says what it is for.** Its docstring gives its purpose within
the module and one short example of using it.
10. **Leave it cleaner than you found it.** Code you visit or change leaves
more readable and more testable than it came, until it follows these
rules. Much of the existing code predates them; this is how it gets there,
one touched function at a time, with its behaviour pinned by a test before
it moves.

## Commits: the subject is the changelog

The release log is the changelog. `stack update` prints it, the GitHub release
Expand Down
15 changes: 15 additions & 0 deletions lib/stack/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -2105,6 +2105,21 @@ def main():
action = getattr(args, "action", None)
if not action:
parser.parse_args([args.command, "--help"]); return
if json_mode:
# The plugin contract: a command returns a dict and the framework
# renders it. With --json that dict is the only thing on stdout;
# what the command prints for people goes to stderr, and the flag
# is taken out of the arguments before the command reads them.
import contextlib
_remaining = [a for a in _remaining if a != "--json"]
sys.argv = [a for a in sys.argv if a != "--json"]
with contextlib.redirect_stdout(sys.stderr):
result = stck.run_cli_command(args.command, action, _remaining)
json.dump(result or {}, sys.stdout, indent=2, default=str)
print()
if result and "error" in result:
sys.exit(1)
return
result = stck.run_cli_command(args.command, action, _remaining)
if result and "error" in result:
print_error(result); sys.exit(1)
Expand Down
24 changes: 23 additions & 1 deletion stacklets/messages/cli/_matrix.py
Original file line number Diff line number Diff line change
Expand Up @@ -450,7 +450,7 @@ def list_users(self):

# ── Messaging ────────────────────────────────────────────────────────

def send(self, room, message, html=None, mentions=None, thread_root=None):
def send(self, room, message, html=None, mentions=None, thread_root=None, reply_to=None):
"""Send a text message to a room (by alias or ID).

Resolves aliases automatically. If html is provided, sends a
Expand All @@ -464,6 +464,10 @@ def send(self, room, message, html=None, mentions=None, thread_root=None):
event id, the way a client's "Reply in thread" does. A thread is a
conversation the agent treats as addressed to it once it is part of
one, so this is how that path is exercised from the terminal.

`reply_to` answers one message without starting a thread, the way a
client's "Reply" does. For the archivist a reply to its filing is a
correction, so this is how that path is exercised.
"""
if room.startswith("!"):
room_id = room
Expand All @@ -488,6 +492,8 @@ def send(self, room, message, html=None, mentions=None, thread_root=None):
"is_falling_back": True,
"m.in_reply_to": {"event_id": thread_root},
}
elif reply_to:
body["m.relates_to"] = {"m.in_reply_to": {"event_id": reply_to}}
status, resp = _put(
self._url(f"/_matrix/client/v3/rooms/{room_id}/send/m.room.message/{txn}"),
body,
Expand All @@ -497,6 +503,22 @@ def send(self, room, message, html=None, mentions=None, thread_root=None):
return True, resp.get("event_id", "sent")
return False, resp.get("error", "unknown error")

def react(self, room, event_id, key):
"""React to a message with `key` (an emoji), as the logged-in user.
Returns (ok, event id or error)."""
room_id = room if room.startswith("!") else self.resolve_room(room)
if not room_id:
return False, f"Room '{room}' not found"
txn = f"{int(time.time() * 1000)}_{random.randint(0, 0xFFFFFFFF):08x}"
status, resp = _put(
self._url(f"/_matrix/client/v3/rooms/{room_id}/send/m.reaction/{txn}"),
{"m.relates_to": {"rel_type": "m.annotation", "event_id": event_id, "key": key}},
token=self.token,
)
if status == 200:
return True, resp.get("event_id", "sent")
return False, resp.get("error", "unknown error")

def join(self, room):
"""Join a room by alias or ID as the logged-in user. Idempotent —
Synapse returns 200 if you are already a member."""
Expand Down
54 changes: 54 additions & 0 deletions stacklets/messages/cli/react.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
"""
stack messages react <room> <event-id> <emoji> [--as <user>] — react to a message

Adds a reaction to a message, as stacker-bot or, with `--as <user>`, as a
family member. Reactions are how the family tells the archivist what to do
with one message: 📌 keeps it, 📎 archives its source, 🔁 retries a filing.
The event id comes from `stack messages read --ids` or from the send that
posted the message.

Examples:
stack messages react picnic '$abc...' 📌 --as marge
"""

HELP = "React to a message in a chat room"

import sys
from pathlib import Path

_here = Path(__file__).parent
sys.path.insert(0, str(_here))
from _matrix import MatrixClient, resolve_login


def run(args, stacklet, config):
if not config["is_healthy"]():
return {"error": "Messages is not running — start it with 'stack up messages'"}

argv = list(args or [])
sender = None
if "--as" in argv:
i = argv.index("--as")
if i + 1 >= len(argv):
return {"error": "--as needs a username"}
sender = argv[i + 1]
del argv[i:i + 2]
if len(argv) != 3:
return {"error": "Usage: stack messages react <room> <event-id> <emoji> [--as <user>]"}
room, event_id, emoji = argv

username, password, err = resolve_login(sender, config.get("secrets", {}))
if err:
return {"error": err}
server_name = config.get("stack", {}).get("messages", {}).get("server_name", "home")
synapse_port = config.get("manifest", {}).get("ports", {}).get("synapse", 42031)
client = MatrixClient(f"http://localhost:{synapse_port}", server_name,
config.get("instance_dir", config.get("repo_root", ".")))
if not client.login(username, password):
return {"error": f"{username} can't log in — check the password in secrets."}

ok, detail = client.react(room, event_id, emoji)
if not ok:
return {"error": f"Failed to react: {detail}"}
print(f" Reacted {emoji} in {room}: {detail}")
return {"ok": True, "room": room, "event_id": detail, "reacted_to": event_id}
Loading
Loading