feat(plugin): package as a Claude Code marketplace plugin

- .claude-plugin/plugin.json v0.1.0 (auto_discover, MIT)
- SessionStart hook: thin zero-dep .mjs wrapper (marketplace convention)
  around scripts/coord-inbox.sh, emitting the additionalContext envelope;
  always exits 0. Smoke-tested: empty mailbox -> bare continue, pending
  message -> injected with UNTRUSTED framing.
- coord-send skill bundled; examples and description use generic repo
  names only (coordination metadata never reaches a public surface).
- README (English; documents the deliver-until-done lifecycle correctly,
  review §7), CHANGELOG, LICENSE (MIT), CLAUDE.md, package.json +
  node --test wrapper around the bash selftest.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBbjgS5A55RVavoyjJC4FX
This commit is contained in:
Kjell Tore Guttormsen 2026-07-24 06:46:09 +02:00
commit 0e6014fe98
10 changed files with 472 additions and 0 deletions

View file

@ -0,0 +1,12 @@
{
"name": "coord",
"version": "0.1.0",
"description": "Local mailbox for coordination between Claude Code sessions in different repositories. Directed messages and broadcasts as plain Markdown files on your own disk, injected as context at session start. Local, private, no network.",
"author": {
"name": "Kjell Tore Guttormsen"
},
"auto_discover": true,
"license": "MIT",
"repository": "https://git.fromaitochitta.com/open/coord",
"keywords": ["coordination", "mailbox", "inter-repo", "multi-repo", "session-start"]
}

44
CHANGELOG.md Normal file
View file

@ -0,0 +1,44 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.1.0] - 2026-07-24
First public release: extraction of a battle-tested personal mechanism into a
distributable plugin.
### Added
- Bash engine: `coord-send.sh` (directed / broadcast / reply delivery),
`coord-inbox.sh` (pending inbox + unseen broadcasts, formatted for context
injection), `coord-done.sh` (archive without reply), all bash-3.2-safe,
ASCII-only, zero dependencies.
- `coord-selftest.sh`: 48 checks against a throwaway mailbox, including
regression cover for filename sanitization, no-hang argument parsing,
prompt-injection resistance, frontmatter hygiene, and broadcast ordering.
- SessionStart hook (`hooks/scripts/session-start.mjs`): thin zero-dependency
Node wrapper that injects the repo's pending mailbox as
`additionalContext`. Always exits 0 — a broken mailbox never blocks a session.
- `coord-send` skill: natural-language front door (English + Norwegian
triggers) mapping intent to engine invocations, including bounded
multi-target loops, deferred sends, and replies.
### Fixed
- Frontmatter injection: CR/LF and control characters in `--subject`/`--from`
are collapsed before writing, so fields can no longer inject frontmatter
lines or a premature `---` terminator.
- Broadcast loss: a per-repo seen set (`_broadcast/seen/<repo>`, one delivered
filename per line) replaces the single high-water mark, which silently
dropped a same-second broadcast whose filename sorted below one already
seen. Upgrade note: a legacy watermark file is read as a one-entry seen
set, so old broadcasts may be re-delivered once.
### Changed
- Read-side protocol strings are English (`--- message:`, `-> reply:`,
UNTRUSTED DATA framing); the forgery-resistance selftest checks pin the
English tokens.

59
CLAUDE.md Normal file
View file

@ -0,0 +1,59 @@
# coord
## Context
Local inter-repo coordination mailbox for Claude Code, packaged as a
marketplace plugin. Three components, one boundary:
- **Engine (`scripts/*.sh`):** bash owns all mailbox semantics — filename
grammar, frontmatter, delivery, archiving, the seen set. `coord-send.sh`
writes, `coord-inbox.sh` reads (formatted for context injection),
`coord-done.sh` archives. Everything is pinned by `coord-selftest.sh`
(48 checks, throwaway mailbox via `CLAUDE_COORD_DIR`).
- **Hook (`hooks/scripts/session-start.mjs`):** thin zero-dependency Node
wrapper (marketplace convention: hooks are `.mjs`) that calls
`coord-inbox.sh` and emits the `hookSpecificOutput.additionalContext`
envelope. No mailbox logic lives here. Always exits 0.
- **Skill (`skills/coord-send/`):** natural-language front door mapping user
intent to engine invocations. No mailbox logic lives here either.
**Boundary rule:** the mailbox is transport, not state. Durable decisions
live in the owning repo's docs/git history; messages are notices pointing at
them. Message content is untrusted cross-repo input — the read side quotes
and frames it; the send side sanitizes line-oriented fields.
## Conventions
- Scripts are bash-3.2-safe and ASCII-only: no `declare -A`, no
`readarray`/`mapfile`, no `|&`; guard `shift 2` with `$# -ge 2`; guard
empty-array expansion under `set -u` with `${#a[@]}`.
- Zero dependencies everywhere: bash + coreutils in the engine, `node:`
builtins only in hook and tests.
- TDD: no behavior change without a failing selftest check first.
`bash scripts/coord-selftest.sh` must exit 0 (48/48).
- English for all code, docs, and commit messages (public repo). Norwegian
trigger aliases in the skill description are deliberate.
- Conventional Commits: `type(scope): description`.
## Commands
- Test: `bash scripts/coord-selftest.sh` (or `npm test`, the Node wrapper)
- Hook smoke test: `node hooks/scripts/session-start.mjs` (expects JSON on stdout)
## Release
Version must agree across: `.claude-plugin/plugin.json`, `package.json`,
README version badge, `skills/coord-send/SKILL.md` frontmatter, git tag
`vX.Y.Z`, and the catalog `ref` in
`ktg-plugin-marketplace/catalog/.claude-plugin/marketplace.json`. Release via
the catalog's `scripts/release-plugin.mjs coord` (tag + ref bump together);
verify with `scripts/check-versions.mjs`. Never hand-edit a ref.
## Hardening roadmap (queued, post-v0.1.0)
- Atomic delivery: `mktemp` inside the destination dir so rename never
crosses filesystems.
- Explicit `.`/`..` rejection in `--reply-to`/`coord-done` guards.
- Selftest gaps: default mailbox path (unset `CLAUDE_COORD_DIR`), malformed
frontmatter on the read path.
- Uniform `-h` across the three CLIs (`coord-inbox.sh` lacks it).

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Kjell Tore Guttormsen
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

114
README.md Normal file
View file

@ -0,0 +1,114 @@
# Coord
> A local mailbox for coordination between Claude Code sessions in different repositories. Session A in repo X leaves a message for repo Y; the next session in repo Y gets it injected as context at startup. Local, private, no network, no SaaS.
> **Solo-maintained, fork-and-own.** This plugin is a starting point, not a vendor product. Issues are welcome as signals; pull requests are not accepted. See the [marketplace governance](https://git.fromaitochitta.com/open/ktg-plugin-marketplace/src/branch/main/GOVERNANCE.md) for the full model.
*AI-generated: all code produced by Claude Code through dialog-driven development.*
![Version](https://img.shields.io/badge/version-0.1.0-blue)
![Platform](https://img.shields.io/badge/platform-Claude_Code_Plugin-purple)
![Hooks](https://img.shields.io/badge/hooks-1-green)
![Skills](https://img.shields.io/badge/skills-1-orange)
![License](https://img.shields.io/badge/license-MIT-lightgrey)
---
## Why This Exists
When you work with an AI coding agent across several repositories, the sessions are islands. A decision made in repo X often matters to repo Y — a shared spec changed, a bug another repo depends on got fixed, a gate was re-pinned. Without a channel, you are the messenger: you remember to mention it in the next session, or it gets lost.
Coord is that channel, reduced to the simplest thing that works: a directory of Markdown files on your own disk. Sending is writing a file; receiving is a SessionStart hook that injects your repo's pending messages as context. No server, no daemon, no network, no accounts.
**Transport, not state.** A coord message is a notice, not a source of truth. The durable record of any decision lives in the owning repo (its docs, its git history). Messages point at that record; they never replace it.
## How It Works
Mailbox layout (default `~/.claude/coord/`, override with `CLAUDE_COORD_DIR`):
<repo>/inbox/ pending directed messages TO <repo>
<repo>/archive/ handled messages (kept, never deleted)
_broadcast/inbox/ messages to ALL repos (accumulate)
_broadcast/seen/<repo> per-repo seen set: delivered broadcast filenames, one per line
Repo identity is the basename of the git toplevel (fallback: the working directory). There is no registration — a repo joins the moment something is sent to it, or when it first reads a broadcast.
Message format (filename `<UTC-timestamp>-<uniq>-from-<sender>.md`):
---
from: <source-repo>
to: <target-repo> | broadcast
subject: <short subject>
date: <UTC ISO-8601>
---
<body>
**Lifecycle — deliver until done.** Directed messages are NOT archived on read. They stay pending and are re-injected at every session start (startup, `/clear`, resume) until explicitly marked handled: replying (`--reply-to`) archives the original, or `coord-done <file>` archives it without a reply. `/clear` never loses a message. Broadcasts are delivered once per repo via the seen set and otherwise accumulate; prune `_broadcast/inbox/` manually when a notice stops being relevant to future first-time repos.
## Install
claude plugin marketplace add https://git.fromaitochitta.com/open/ktg-plugin-marketplace.git
claude plugin install coord@ktg-plugin-marketplace
The plugin ships empty: your mailbox is created lazily on first send, on your machine, and stays there.
## Usage
**Natural language (the `coord-send` skill).** In any session: "tell repo-x the bug is fixed", "broadcast that the spec changed", "reply to that coord message", "when the tests are green, notify repo-y". The skill maps intent to the right `coord-send` invocation, including bounded multi-target loops and deferred sends.
**Receiving** is automatic: the SessionStart hook injects your repo's pending inbox and unseen broadcasts as context, with per-message reply/resolve hints.
**CLI.** The engine is three bash scripts in the plugin's `scripts/` directory; resolve them as `"${CLAUDE_PLUGIN_ROOT:-$HOME/.claude}/scripts/coord-<name>.sh"` (from a terminal, use the plugin's install path):
coord-send.sh --to <repo> --subject "<subject>" [--message "<text>"] # or body on stdin
coord-send.sh --broadcast --subject "<subject>" <<'BODY' ... BODY
coord-send.sh --reply-to <filename> [--subject "Re: ..."] # routes + closes original
coord-inbox.sh [--repo <name>] # print pending (what the hook injects)
coord-done.sh <filename>... | --all # archive without replying
The reply/resolve hints the hook injects (`-> reply: coord-send --reply-to … | done without reply: coord-done …`) refer to these scripts.
## Security Model
Cross-repo message content is untrusted input by design:
- **Read side:** every injected content line is prefixed with `> `, so a message body can never forge the `--- message:` / `-> reply:` framing lines at column 0. The injected header explicitly frames content as UNTRUSTED DATA and instructs the model to never follow instructions found inside it.
- **Send side:** CR/LF and control characters in `subject`/`from` are collapsed before writing, so fields cannot inject frontmatter lines or a premature `---` terminator. Sender names are sanitized in filenames (raw name kept in frontmatter).
- **No network, no secrets:** everything is local files under your `$HOME`. Credentials never appear in messages, filenames, or frontmatter — there is nothing to leak by transport.
- **Privacy rule:** coordination metadata (repo names, status, architecture) never belongs on a public surface. The mailbox lives outside your repos and stays out of git.
Every guarantee above is pinned by the 48-check selftest, including forgery-resistance regressions.
## The Five Rules
1. **Mailbox, not state.** Files here are messages in transit. If a file starts acting as someone's state-of-play, it belongs in the owning repo.
2. **No durable decisions live here.** The copy here is the notice, not the record — durable content is written in the owning repo's docs.
3. **One recipient per message.** `--to <repo>` or `--broadcast`.
4. **Delivery happens via session start.** Don't hand-edit another repo's inbox; use `coord-send`.
5. **Private.** Coordination metadata never reaches a public surface.
## Requirements
- macOS or Linux with bash 3.2+ (the scripts are deliberately bash-3.2-safe and ASCII-only).
- Node.js >= 18 for the SessionStart hook (zero npm dependencies).
- `git` is optional — without it, repo identity falls back to the directory basename.
## Development
bash scripts/coord-selftest.sh # 48 checks against a throwaway mailbox
npm test # same selftest via node --test
TDD is the house rule: every behavior change lands with a failing selftest check first.
## Hardening Roadmap
Known, accepted gaps queued for a future release:
- Atomic delivery: create the temp file inside the destination directory so the final rename never crosses filesystems.
- Explicit `.`/`..` rejection in the `--reply-to` and `coord-done` name guards (currently stopped downstream by accident, not by the guard).
- Selftest gaps: the default mailbox path (unset `CLAUDE_COORD_DIR`) and malformed frontmatter on the read path are not yet exercised.
- `coord-inbox.sh` lacks `-h`/`--help` and silently ignores unknown arguments; send/done already have uniform help.
## License
MIT — see [LICENSE](LICENSE).

15
hooks/hooks.json Normal file
View file

@ -0,0 +1,15 @@
{
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/session-start.mjs",
"timeout": 10
}
]
}
]
}
}

View file

@ -0,0 +1,42 @@
#!/usr/bin/env node
// coord - SessionStart hook: inject this repo's pending coordination inbox
// (directed messages + unseen broadcasts) as additionalContext.
//
// Thin Node wrapper (marketplace convention: hooks are .mjs) around the bash
// engine scripts/coord-inbox.sh, which owns the mailbox semantics and is
// covered by scripts/coord-selftest.sh. Zero dependencies. Always exits 0 -
// a broken mailbox must never block a session.
import { execFileSync } from 'node:child_process';
import { basename, dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
function emit(context) {
const out = { continue: true };
if (context) {
out.hookSpecificOutput = { hookEventName: 'SessionStart', additionalContext: context };
}
process.stdout.write(JSON.stringify(out) + '\n');
}
try {
const pluginRoot = process.env.CLAUDE_PLUGIN_ROOT
|| join(dirname(fileURLToPath(import.meta.url)), '..', '..');
let repoRoot = '';
try {
repoRoot = execFileSync('git', ['rev-parse', '--show-toplevel'],
{ stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf8' }).trim();
} catch {
// not a git repo: fall back to the working directory below
}
if (!repoRoot) repoRoot = process.cwd();
const inbox = execFileSync('bash',
[join(pluginRoot, 'scripts', 'coord-inbox.sh'), '--repo', basename(repoRoot)],
{ stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf8' });
emit(inbox.trim() ? '== Repo coordination (unread messages) ==\n' + inbox : '');
} catch {
emit('');
}

12
package.json Normal file
View file

@ -0,0 +1,12 @@
{
"name": "coord",
"version": "0.1.0",
"private": true,
"type": "module",
"engines": {
"node": ">=18"
},
"scripts": {
"test": "node --test tests/*.test.mjs"
}
}

140
skills/coord-send/SKILL.md Normal file
View file

@ -0,0 +1,140 @@
---
name: coord-send
description: >-
Send an inter-repo coordination message through the local coord mailbox — a
natural-language front door over the `coord-send` script. Use this whenever the
user wants to notify, tell, message, ping, or coordinate with another repository
(or all repositories) about work happening in the current repo: "send info to all
repos", "tell repo-x the bug is fixed", "let repo Y and Z know",
"broadcast that the spec changed", "reply to that coord message", or a
deferred/conditional notice like "when the build is green, notify repo X". Also
triggers on Norwegian phrasings: "send info til alle repo", "varsle repo X", "gi
beskjed til Y og Z", "kringkast at …", "svar på coord-meldingen", "når Z er ferdig,
varsle X". Trigger even when the user names a repo plus something to convey without
saying "coord" explicitly — routing a message to another repo IS this skill.
version: "0.1.0"
---
# coord-send — natural-language front door for inter-repo messages
This skill turns a plain request ("tell repo X that Y happened") into the right
`coord-send` invocation. The script is the engine; this skill is only the mapping
from intent to arguments. It never edits mailbox files by hand — always go through
the script, because the script owns the filename grammar, frontmatter, and delivery
guarantees.
## Boundary — why messages are thin
The mailbox is **transport, not state**. A coord message is a *notice*, not the
source of truth. The durable record of any decision lives in the owning repo's
state files / docs and its git history. So when you compose a body, write a
self-contained heads-up and point at where the real record lives (a commit
hash, a doc path) — don't try to make the message itself the canonical artifact.
Coordination content is also private: it never belongs on a public surface.
## The engine
Resolve the script path portably — this one expression is correct both when the
skill runs bundled inside the plugin and when the scripts are installed as
personal scripts under `~/.claude/scripts/`:
CSEND="${CLAUDE_PLUGIN_ROOT:-$HOME/.claude}/scripts/coord-send.sh"
Interface (body comes from a quoted heredoc so nothing in it is shell-expanded):
# to one repo
"$CSEND" --to <repo> --subject "<subject>" <<'BODY'
<message body>
BODY
# to every repo (accumulates; first-time newcomers see it too)
"$CSEND" --broadcast --subject "<subject>" <<'BODY'
<message body>
BODY
# reply to a message this repo received (routes to the original sender and
# marks the original handled; target + "Re: …" subject are inferred)
"$CSEND" --reply-to <message-filename> <<'BODY'
<message body>
BODY
Sender identity (`--from`) defaults to the basename of the current git toplevel, so
you almost never set it. Exit 0 = delivered; exit 2 = usage/IO error (read stderr and
fix the arguments rather than retrying blindly).
## Choosing the target
- **One named repo**`--to <repo>`. The repo name is its directory basename; use
the name the user gave. No registration exists — sending to a new name just creates
that repo's inbox, so a typo silently creates a dead mailbox. If unsure a name is
real, check `~/repos/` or existing `~/.claude/coord/<repo>/` before sending.
- **Several named repos** ("X and Y") → loop `--to` once per repo with the same
subject and body. There is no multi-target flag; the loop is the mechanism:
for repo in repo-x repo-y; do
"$CSEND" --to "$repo" --subject "<subject>" <<'BODY'
<body>
BODY
done
- **All repos**`--broadcast`. Prefer this only when the notice genuinely concerns
everyone, because broadcasts accumulate and every future first-time repo sees the
standing backlog. For a bounded, known set, loop `--to` instead so unrelated future
repos don't inherit it.
- **A reply to something received**`--reply-to <filename>`. Use the filename from
the injected inbox/archive; it resolves the sender and closes the original in one
step.
## Composing the message
Derive a short, specific `--subject` from the intent if the user didn't give one
("ingest bug fixed", not "update"). In the body, state what happened, what the
recipient should know or do, and a pointer to the durable record (commit hash, doc
path). Keep it to the point — this is a notice, not a report. Match the repo's
language convention for message content; keep it sober and factual.
## Deferred and conditional sends
"When Z is done, notify X" means: do **not** send now. Hold the intent — the target,
subject, and body — and continue the work. The moment condition Z is satisfied *in
this session*, fire the send. This works because you carry the intent across turns
within a session.
Be honest about the limit: a skill cannot persist intent across sessions. If the
session is ending and the condition still hasn't been met, surface the pending
message to the user so it isn't silently lost — don't imply it will fire later on
its own.
## After sending
Report what actually happened: the target(s), the subject, and the delivered
filename(s) from the script's stdout. If a send failed (exit 2), say so with the
stderr reason rather than claiming success. Delivery reaches the recipient at *their*
next session start (the receive side is a hook), so tell the user it's queued, not
that the other repo has seen it.
## Examples
**Example 1 — single repo, composed body**
Input: "tell repo-x the ingest bug is fixed, it was commit abc1234"
Action: `--to repo-x --subject "ingest bug fixed"`, body naming the fix and
pointing at commit abc1234 and the owning doc.
**Example 2 — broadcast**
Input: "let all the repos know the shared spec grew a new validation rule"
Action: `--broadcast --subject "spec: new validation rule"`, body summarizing
the change and pointing at the spec's owning repo.
**Example 3 — bounded multi-target**
Input: "gi beskjed til repo-x og repo-y om at gaten er re-pinnet"
Action: loop `--to repo-x` then `--to repo-y`, same subject "shared gate re-pinned".
**Example 4 — deferred**
Input: "når testene er grønne, varsle repo-y"
Action: keep working; once the test run passes this session, send
`--to repo-y` with the green result. If the session ends first, surface the
pending notice to the user.
**Example 5 — reply**
Input: "svar på coord-meldingen fra repo-x at vi tar det"
Action: `--reply-to <that-message-filename>`, body acknowledging and stating the plan.

13
tests/selftest.test.mjs Normal file
View file

@ -0,0 +1,13 @@
// Node wrapper (marketplace convention: node --test) around the bash
// selftest, which owns every mailbox assertion. The selftest runs against a
// throwaway mailbox (mktemp) and exits non-zero on any failing check.
import { test } from 'node:test';
import { execFileSync } from 'node:child_process';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
test('coord bash selftest passes', () => {
execFileSync('bash', [join(root, 'scripts', 'coord-selftest.sh')], { encoding: 'utf8' });
});