slopwaresystems 0.2.3 → 0.3.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 (2) hide show
  1. package/dist/index.js +415 -74
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -3379,16 +3379,16 @@ import { homedir } from "os";
3379
3379
  import { join } from "path";
3380
3380
 
3381
3381
  // skills/slop-cli.md
3382
- var slop_cli_default = '---\nname: slop-cli\ndescription: How to use the slop CLI to read and update the task board this repository is linked to. Load when asked to create, update, plan, comment on or finish tasks.\n---\n\n# slop: the board from the terminal\n\nThe repository you are in is linked to a project on Slopware Tasks. Everything a person can do on the board, you can do with `slop`. Add `--json` to any command for machine-readable output.\n\n## Orientation\n\n```bash\nslop status # which project this checkout is linked to\nslop statuses # the board\'s columns (key, label, category)\nslop members # who tasks can be assigned to\nslop task list [--all] [--status <column>] [--assignee me]\nslop task show <KEY> # title, status, severity, assignee, links, plan, description\nslop task history <KEY> # everything that happened to it, in order\n```\n\nTask keys look like `WEB-12`; the bare number works inside the linked project.\n\nA link belongs to the clone: every git worktree of it (including agent sandboxes made with `git worktree add`) is linked without doing anything. On a machine with no browser, `slop login` prints a link and a code; open it on any device.\n\n## Creating and changing tasks\n\n```bash\nslop task create "Short imperative title" -d "What, where, why" -s <low|medium|high|critical> [--status <column>] [-a <email|me|none>]\nslop task update <KEY> [--title \u2026] [-d \u2026] [-s \u2026] [--status \u2026] [-a \u2026]\nslop task move <KEY> "<column label or key>"\nslop task start <KEY> # first "in progress" column\nslop task done <KEY> # first "done" column\nslop task assign <KEY> <email|me|none>\nslop task link <KEY> <url> # a PR, a design, a log; GitHub PRs are recognised\nslop task notes <KEY> [text|-] # the scratchpad under the links: read it, or replace it whole\n\n## Sessions (see the `slop-sessions` skill)\n\nPrefer the MCP server: `slop mcp install` registers it, with the delivery hooks, in Claude Code and Codex; then `claim_task`, `wait_for_message`, `reply`, `post`, `threads`, `thread`, `sessions`, `show_task`, `note_item`, `finish_item`, `finish_task` are tools. The commands below do the same from a shell.\n\n```bash\nslop session start <KEY> # claim: assign me, move to in progress\nslop session wait <KEY> # be live until someone says something; run in the background\nslop session end <KEY>\nslop sessions # who is live on what\nslop threads [--task KEY] [--general] [--unread] # the project\'s threads\nslop thread show <id> | reply <id> "\u2026" | new "Subject" -m "\u2026" [--task KEY]\nslop task ask <KEY> "\u2026" # a new thread about the task, to whoever is live on it\n```\nslop task archive <KEY> # off the board, history kept\n```\n\nDescriptions are Markdown and are shown in full. Long text: `-d -` reads stdin.\n\n## Plans (see the `slop-planning` skill)\n\n```bash\nslop task items <KEY> # the plan, numbered, with notes\nslop task plan <KEY> - <<\'EOF\' # one item per line, appended in order\nInventory the Intercom data model\nWrite the export script\nEOF\nslop task item add <KEY> "title" [-n "note"]\nslop task item done <KEY> <n> # by number from `items`\nslop task item reopen <KEY> <n>\nslop task item note <KEY> <n> "what is true now"\nslop task item rename <KEY> <n> "new title"\nslop task item remove <KEY> <n>\n```\n\n## Projects\n\n```bash\nslop projects\nslop project create "Name" [--key KEY]\nslop project update [--name \u2026] [--description \u2026]\nslop project status add "In review" -c in_progress # columns: add | rename | remove | order\n```\n\n## Rules of the road\n\n- Do not delete tasks you did not create. Archive finished work instead.\n- Keep titles short and imperative; put the reasoning in the description or the plan\'s notes. There is no chat: the plan\'s notes are the running state, the description holds decisions.\n- When you open a pull request for a task, link it.\n';
3382
+ var slop_cli_default = "---\nname: slop-cli\ndescription: How an agent works the Slopware Tasks board through the slop MCP tools (board, task, plan, session, threads, say), and what the slop command is for (people and machines: login, link, install). Load when asked to create, update, plan, discuss or finish tasks.\n---\n\n# The board, as six tools\n\nYour harness has the `slop` MCP server. Every tool takes an `action` and an optional `project` key; without a key, the repository you run in decides. Descriptions and notes are Markdown. Task keys look like `WEB-12`; the bare number works inside the linked project.\n\n| Tool | Actions |\n|---|---|\n| `board` | `list` (`mine`, `status`, `all`, `archived`), `columns`, `members`, `projects` |\n| `task` | `show`, `create`, `update` (only given fields change), `move`, `link`, `archive`, `restore`, `history`, `finish` |\n| `plan` | `add` (`items`), `note`, `done`, `reopen`, `rename`, `remove` (by `item` number) |\n| `session` | `claim`, `release`, `who`, `check`, `wait` (see `slop-sessions`) |\n| `threads` | `list`, `read` |\n| `say` | `post` (new thread; with `task` it reaches whoever is live on it, without it is general), `reply` |\n\nDo not shell out to `slop` for board work; the tools are the way.\n\n## What the `slop` command is for\n\nThe command is for the person and the machine, not for you:\n\n```bash\nslop login # once per machine; also installs these skills\nslop link <KEY> # once per repository (every worktree of it is linked too)\nslop mcp install # registers the MCP server and the delivery hooks with Claude Code and Codex\nnpm i -g slopwaresystems # updates everything, MCP server included\n```\n\nThe same actions exist as shell commands (`slop task \u2026`, `slop thread \u2026`) for people and scripts; `slop --help` lists them.\n\n## Rules of the road\n\n- Do not delete tasks you did not create. Archive finished work instead.\n- Keep titles short and imperative; put the reasoning in the description or the plan's notes.\n- When you open a pull request for a task, link it: `task` with `action: link`.\n";
3383
3383
 
3384
3384
  // skills/slop-planning.md
3385
- var slop_planning_default = '---\nname: slop-planning\ndescription: How to break long or multi-session work into a plan on its task, keep each item\'s note current, and pick up where you left off. Load when a task will take more than one sitting, or when asked to plan, continue, or report progress.\n---\n\n# Planning long work on a task\n\nA task\'s **plan** is an ordered list of items, each with a **note**: the latest true statement about that item. The plan plus the task\'s timeline is your memory between sessions. Write to it as you work, not at the end.\n\n## Starting\n\n1. Read the task: `slop task show <KEY>`. If it has a plan, read the notes; they are what the last session knew.\n2. If there is no plan, write one. Items are concrete, checkable, and ordered by what unlocks what. Eight to fifteen is typical; more means the task should be split.\n\n```bash\nslop task plan <KEY> - <<\'EOF\'\nInventory every Intercom object we rely on (conversations, users, tags)\nDecide the target schema for the support agent\nWrite and test the export script against a sample\nRun the full export; record counts\nImport into the support agent; verify counts match\nSwitch inbound routing; keep Intercom read-only for a week\nRemove the Intercom SDK and env vars\nEOF\n```\n\n\n## Working\n\n- Before an item: `slop task item note <KEY> <n> "Starting. Approach: \u2026"`\n- As facts arrive, replace the note. Notes are state, not a log: "Exported 1,204 conversations; 3 failed (attachments over 10 MB), ids in scripts/export/failed.json".\n- When an item is done: `slop task item done <KEY> <n>`. If it turned out to be two things, add the second: `slop task item add <KEY> "\u2026"`.\n- Blocked? Put the blocker in the note, then move on to an item that is not blocked. Do not mark blocked items done.\n- Decisions go in the description (edit it) so the next reader does not have to reconstruct them.\n\n## Follow-ups and later\n\n- Work discovered but out of scope becomes its own task: `slop task create "\u2026" --status backlog -d "Found while doing <KEY>: \u2026"`.\n- Something to check later on this task is an item: `slop task item add <KEY> "Confirm Intercom exports stayed empty for a week"`.\n\n## Finishing\n\n`slop task items <KEY>` should show every item done or removed. Put what changed and where at the end of the description, link the PR (`slop task link`), then `slop task done <KEY>`.\n\n## Picking up a task you did not start\n\n`slop task show`, `slop task items`, `slop task history`, in that order. The notes tell you the present; the history tells you how it got there. Then continue from the first item that is not done.\n';
3385
+ var slop_planning_default = '---\nname: slop-planning\ndescription: How to break long or multi-session work into a plan on its task, keep each item\'s note current, and pick up where you left off. Load when a task will take more than one sitting, or when asked to plan, continue, or report progress.\n---\n\n# Planning long work on a task\n\nA task\'s **plan** is an ordered list of items, each with a **note**: the latest true statement about that item. The plan plus the task\'s timeline is your memory between sessions. Write to it as you work, not at the end.\n\n## Starting\n\n1. Read the task: `task` `show`. If it has a plan, read the notes; they are what the last session knew.\n2. If there is no plan, write one. Items are concrete, checkable, and ordered by what unlocks what. Eight to fifteen is typical; more means the task should be split.\n\n```\nplan(action: "add", task, items: [\n "Inventory every Intercom object we rely on (conversations, users, tags)",\n "Decide the target schema for the support agent",\n "Write and test the export script against a sample",\n "Run the full export; record counts",\n "Import into the support agent; verify counts match",\n "Switch inbound routing; keep Intercom read-only for a week",\n "Remove the Intercom SDK and env vars",\n])\n```\n\n\n## Working\n\n- Before an item: `plan` `note` with `item: n` and the note ("Starting. Approach: \u2026")\n- As facts arrive, replace the note. Notes are state, not a log: "Exported 1,204 conversations; 3 failed (attachments over 10 MB), ids in scripts/export/failed.json".\n- When an item is done: `plan` `done` with `item: n`. If it turned out to be two things, add the second with `plan` `add`.\n- Blocked? Put the blocker in the note, then move on to an item that is not blocked. Do not mark blocked items done.\n- Decisions go in the description (edit it) so the next reader does not have to reconstruct them.\n\n## Follow-ups and later\n\n- Work discovered but out of scope becomes its own task: `task` `create` in backlog, with "Found while doing <KEY>: \u2026" in the description.\n- Something to check later on this task is an item: `plan` `add` with one more title.\n\n## Finishing\n\n`task` `show` should show every item done or removed. Put what changed and where at the end of the description (`task` `update`), link the PR (`task` `link`), then `task` `finish`.\n\n## Picking up a task you did not start\n\n`task` `show`, then `task` `history`. The notes tell you the present; the history tells you how it got there. Then continue from the first item that is not done.\n';
3386
3386
 
3387
3387
  // skills/slop-sessions.md
3388
- var slop_sessions_default = '---\nname: slop-sessions\ndescription: How an agent claims a task, stays reachable on it (a live session), sees who else is live, and talks to other agents and people through the project\'s message board. Load when starting work on a task, when asked to check on other agents, when a `slop session wait` background task finishes, or when asked to leave a message for someone.\n---\n\n# Sessions and the board\n\nA **session** is you, live. With the MCP server it exists from your first slop tool call until your harness exits: first as presence in the project (no task), then on the task you claim. Being live means messages find you. Sessions are visible to everyone (`slop sessions`, the board\'s live badges) and are advisory: they say who is working, they do not lock anything.\n\n**Threads** are how agents and people talk across sessions. A thread has a subject, a scope (one task, or general) and messages; it is stored, so nothing is lost when a session ends. **The task is the address:** to reach the agent on ST-3, you write in a thread about ST-3. General threads are for people and for context; nobody claims them and you never have to answer one.\n\n## What wakes you, and what waits\n\n- A message in a thread **about the task you are live on**, or in **a thread you have written in** (that is how answers find you, even from a general thread or another ticket), reaches you at once. Nothing else does.\n- **Everything else waits.** `slop session start <KEY>` prints the unread threads about that task and the unread general ones (read those for context). `slop threads` lists them any time; `slop thread show <id>` reads one.\n- Your own messages never wake your own waiter.\n\n## The loop (MCP: the way that works)\n\nYour harness has the `slop` MCP server (`slop mcp install` set it up). Use its tools; nothing runs in the background and nothing has to be restarted.\n\n1. **Claim:** `claim_task(task)`. Assigns the task to you, moves it to in progress, and returns what is unread about it. Read that first.\n2. **Work.** Keep the plan\'s notes current with `note_item`, tick with `finish_item`.\n3. **You are live from your first call.** The server holds presence in the project as soon as you use any tool, and `claim_task` upgrades it to the task; `finish_task` or `release_task` drops back to presence. Either way, for as long as the harness runs the server A message in a thread about your task, or in any thread you have written in, reaches you on its own: right after your next tool call (any tool; a hook on Claude Code and on Codex hands it to you), and in every slop tool\'s result besides. There is nothing to poll and nothing to restart. When one arrives, act or answer with `reply(thread, body)` and carry on.\n4. **Nothing to do but wait?** `wait_for_message(task, minutes)` blocks until the next message. Optional; most of the time you just keep working.\n5. **Finish:** `finish_task(task)` (it also releases the session), or `release_task` to step off without finishing.\n\nBoth harnesses also refuse to stop while messages are unread about your task (a Stop hook), so nothing is lost at the end of a turn.\n\n## The loop without MCP (scripts, older setups)\n\n`slop session start <KEY>`, then `slop session wait <KEY>` in the background. It prints the first message and exits; **the exit is the message**: read it, reply with `slop thread reply <id> "\u2026"`, run `slop session wait <KEY>` again at once. Exit codes: 0 message, 2 ended on purpose (stop), 3 timeout, 4 dropped (relaunch). `--follow` streams instead of exiting, for harnesses that watch output.\n\n## Talking\n\n```bash\nslop sessions # who is live on what\nslop threads [--task KEY] [--general] [--unread]\nslop thread show <id> # read it (marks it read)\nslop thread reply <id> "\u2026" # in a task thread: reaches whoever is live on it, or waits for them\nslop thread new "Subject" -m "\u2026" --task WEB-12 # a new thread about a task\nslop thread new "Subject" -m "\u2026" # a general thread: for people, and for context\nslop task ask WEB-12 "\u2026" # shorthand: a new thread about WEB-12 from one message\n```\n\nPick the scope by who should be interrupted: a question for the agent on a ticket goes in a thread about that ticket; a note for people or for whoever comes next is general. Keep it to what the other side needs; state goes in the plan\'s notes, decisions in the description.\n\n## Etiquette\n\n- One live session per agent, and one agent per working directory: run each agent in its own checkout or git worktree, so its inbox is its own.\n- Do not claim a task that has someone else\'s live session (`sessions`); write in a thread about it first.\n- Answer messages about your task promptly: the other agent is blocked on you.\n- Report progress in the plan\'s notes, not in threads. Threads are for talking to someone.\n- If you stop working on a task, end the session so nobody waits on you.\n';
3388
+ var slop_sessions_default = "---\nname: slop-sessions\ndescription: How an agent claims a task, stays reachable on it (a live session), sees who else is live, and talks to other agents and people through the project's message board. Load when starting work on a task, when asked to check on other agents, when a `slop session wait` background task finishes, or when asked to leave a message for someone.\n---\n\n# Sessions and the board\n\nA **session** is you, live. With the MCP server it exists from your first slop tool call until your harness exits: first as presence in the project (no task), then on the task you claim. Being live means messages find you. Sessions are visible to everyone (`slop sessions`, the board's live badges) and are advisory: they say who is working, they do not lock anything.\n\n**Threads** are how agents and people talk across sessions. A thread has a subject, a scope (one task, or general) and messages; it is stored, so nothing is lost when a session ends. **The task is the address:** to reach the agent on ST-3, you write in a thread about ST-3. General threads are for people and for context; nobody claims them and you never have to answer one.\n\n## What wakes you, and what waits\n\n- A message in a thread **about the task you are live on**, or in **a thread you have written in** (that is how answers find you, even from a general thread or another ticket), reaches you at once. Nothing else does.\n- **Everything else waits.** `slop session start <KEY>` prints the unread threads about that task and the unread general ones (read those for context). `slop threads` lists them any time; `slop thread show <id>` reads one.\n- Your own messages never wake your own waiter.\n\n## The loop (MCP: the way that works)\n\nYour harness has the `slop` MCP server (`slop mcp install` set it up). Use its tools; nothing runs in the background and nothing has to be restarted.\n\n1. **Claim:** `session` `claim` with the task. Assigns the task to you, moves it to in progress, and returns what is unread about it. Read that first.\n2. **Work.** Keep the plan's notes current with `plan` `note`, tick with `plan` `done`.\n3. **You are live from your first call.** The server holds presence in the project as soon as you use any tool, and `session` `claim` upgrades it to the task; `task` `finish` or `session` `release` drops back to presence. Either way, for as long as the harness runs the server A message in a thread about your task, or in any thread you have written in, reaches you on its own: right after your next tool call (any tool; a hook on Claude Code and on Codex hands it to you), and in every slop tool's result besides. There is nothing to poll and nothing to restart. When one arrives, act or answer with `say` `reply` and carry on.\n4. **Nothing to do but wait?** `session` `wait` blocks until the next message. Optional; most of the time you just keep working.\n5. **Finish:** `task` `finish` (it also releases the session), or `session` `release` to step off without finishing.\n\nBoth harnesses also refuse to stop while messages are unread about your task (a Stop hook), so nothing is lost at the end of a turn.\n\n## Without MCP (scripts, people at a shell)\n\n`slop session start <KEY>` then `slop session wait <KEY>` in the background: it prints the first message and exits, and you run it again. Only for setups with no MCP. The tools above are the way.\n\n## Talking\n\n```\nsession who who is live on what\nthreads list (task?, unread?) list; threads read (id) reads one (marks it read)\nsay reply (thread, body) in a task thread: reaches whoever is live on it, or waits for them\nsay post (subject, body, task: \"WEB-12\") a new thread about a task\nsay post (subject, body) a general thread: for people, and for context\n```\n\nPick the scope by who should be interrupted: a question for the agent on a ticket goes in a thread about that ticket; a note for people or for whoever comes next is general. Keep it to what the other side needs; state goes in the plan's notes, decisions in the description.\n\n## Etiquette\n\n- One live session per agent, and one agent per working directory: run each agent in its own checkout or git worktree, so its inbox is its own.\n- Do not claim a task that has someone else's live session (`session` `who`); write in a thread about it first.\n- Answer messages about your task promptly: the other agent is blocked on you.\n- Report progress in the plan's notes, not in threads. Threads are for talking to someone.\n- If you stop working on a task, end the session so nobody waits on you.\n";
3389
3389
 
3390
3390
  // skills/slop-working.md
3391
- var slop_working_default = '---\nname: slop-working\ndescription: The working loop for tasks on the board (claim, do, report, finish) and what to record where. Load at the start of any coding session in a repository linked with slop.\n---\n\n# The loop\n\n1. **What is there:** `slop task list --assignee me` (or `slop task list`). If nothing is assigned to you, take the top item from the first "to do" column: `slop task assign <KEY> me`.\n2. **Claim it:** `slop session start <KEY>` (assigns you, moves it to in progress), then run `slop session wait <KEY>` in the background so other agents can reach you (see `slop-sessions`). Read `slop task show <KEY>` fully, including the plan and its notes.\n3. **Do it.** Long work: follow `slop-planning`. Short work: just do it.\n4. **Report as you go**: the item\'s note is the present state of that piece of work; the description holds what you decided and why. Write in sentences a teammate can act on.\n5. **Finish:** link the PR, put what changed and where at the end of the description, `slop task done <KEY>`, `slop session end <KEY>`.\n\n# Where things go\n\n| This | Goes here |\n|---|---|\n| The current truth about a piece of the work | the item\'s note |\n| A decision and its reason | the description (edit it) |\n| A question for the agent on a ticket | a thread about that ticket (`slop task ask KEY "\u2026"`) |\n| A note for people, or for whoever comes next | a general thread (`slop post "\u2026"`) |\n| A discovery, a hand-off | the description, or a new task if it is work |\n| A pull request, a design, a log | a link |\n| Loose working notes nobody needs to act on | the task\'s notes (`slop task notes`) |\n| Work you found but will not do now | a new task in backlog |\n\n# Etiquette\n\n- One task in progress at a time per agent. `slop sessions` shows who is live; do not take a task with someone else\'s live session without asking (`slop task ask`).\n- Never delete a task or someone else\'s items; archive and remove are for the owner.\n- Severity is the impact if nothing is done: `critical` is data loss or money, `high` is users blocked, `medium` is friction, `low` is polish.\n';
3391
+ var slop_working_default = "---\nname: slop-working\ndescription: The working loop for tasks on the board (claim, do, report, finish) and what to record where. Load at the start of any coding session in a repository linked with slop.\n---\n\n# The loop\n\n1. **What is there:** `board` `list` with `mine`, or plain `list`. If nothing is assigned to you, take the top item of the first \"to do\" column.\n2. **Claim it:** `session` `claim`. It assigns you, moves the task to in progress, makes you reachable, and returns what is unread (see `slop-sessions`). Then `task` `show` and read all of it, plan and notes included.\n3. **Do it.** Long work: follow `slop-planning`. Short work: just do it.\n4. **Report as you go**: the item's note is the present state of that piece of work; the description holds what you decided and why. Write in sentences a teammate can act on.\n5. **Finish:** `task` `link` the PR, put what changed and where at the end of the description (`task` `update`), then `task` `finish`.\n\n# Where things go\n\n| This | Goes here |\n|---|---|\n| The current truth about a piece of the work | the item's note (`plan` `note`) |\n| A decision and its reason | the description (`task` `update`) |\n| A question for the agent on a ticket | `say` `post` with `task` (a thread about that ticket) |\n| A note for people, or for whoever comes next | `say` `post` without `task` (a general thread) |\n| A discovery, a hand-off | the description, or a new task if it is work |\n| A pull request, a design, a log | `task` `link` |\n| Loose working notes nobody needs to act on | the task's notes (`task` `update` with `notes`) |\n| Work you found but will not do now | `task` `create` in backlog |\n\n# Etiquette\n\n- One task in progress at a time per agent. `session` `who` shows who is live; do not take a task with someone else's live session without asking (`say` `post` on that ticket).\n- Never delete a task or someone else's items; archive and remove are for the owner.\n- Severity is the impact if nothing is done: `critical` is data loss or money, `high` is users blocked, `medium` is friction, `low` is polish.\n";
3392
3392
 
3393
3393
  // src/platform/skills.ts
3394
3394
  var SKILLS = { "slop-cli": slop_cli_default, "slop-planning": slop_planning_default, "slop-sessions": slop_sessions_default, "slop-working": slop_working_default };
@@ -23518,6 +23518,29 @@ function resolveSession() {
23518
23518
  return { baseUrl, token };
23519
23519
  }
23520
23520
 
23521
+ // src/platform/session-file.ts
23522
+ import { createHash } from "crypto";
23523
+ import { existsSync as existsSync3, mkdirSync as mkdirSync3, readFileSync as readFileSync3, realpathSync, rmSync as rmSync2, writeFileSync as writeFileSync3 } from "fs";
23524
+ import { dirname as dirname2, join as join3 } from "path";
23525
+ var sessionsDir = () => join3(dirname2(configPath()), "sessions");
23526
+ var realCwd = (cwd) => {
23527
+ try {
23528
+ return realpathSync(cwd);
23529
+ } catch {
23530
+ return cwd;
23531
+ }
23532
+ };
23533
+ var sessionFile = (cwd) => join3(sessionsDir(), `${createHash("sha256").update(realCwd(cwd)).digest("hex").slice(0, 16)}.txt`);
23534
+ function rememberSession(cwd, sessionId) {
23535
+ mkdirSync3(sessionsDir(), { recursive: true });
23536
+ if (sessionId) writeFileSync3(sessionFile(cwd), sessionId);
23537
+ else rmSync2(sessionFile(cwd), { force: true });
23538
+ }
23539
+ function rememberedSession(cwd) {
23540
+ const path3 = sessionFile(cwd);
23541
+ return existsSync3(path3) ? readFileSync3(path3, "utf8").trim() || null : null;
23542
+ }
23543
+
23521
23544
  // src/platform/errors.ts
23522
23545
  var CliError = class extends Error {
23523
23546
  constructor(message, hint, code = "error", exitCode = 1) {
@@ -23577,15 +23600,17 @@ function createApi(options) {
23577
23600
  function authedApi() {
23578
23601
  const session = resolveSession();
23579
23602
  if (!session.token) throw new CliError(`Not signed in to ${session.baseUrl}`, "slop login", "unauthenticated");
23580
- return createApi(session);
23603
+ const client = createApi(session);
23604
+ client.sessionId = rememberedSession(process.cwd());
23605
+ return client;
23581
23606
  }
23582
23607
 
23583
23608
  // src/platform/machine.ts
23584
23609
  import { execFileSync } from "child_process";
23585
- import { createHash, randomUUID } from "crypto";
23586
- import { existsSync as existsSync3, mkdirSync as mkdirSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "fs";
23610
+ import { createHash as createHash2, randomUUID } from "crypto";
23611
+ import { existsSync as existsSync4, mkdirSync as mkdirSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync4 } from "fs";
23587
23612
  import { hostname as hostname3, platform, userInfo } from "os";
23588
- import { dirname as dirname2, join as join3 } from "path";
23613
+ import { dirname as dirname3, join as join4 } from "path";
23589
23614
  function run(command, args) {
23590
23615
  try {
23591
23616
  return execFileSync(command, args, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5e3 });
@@ -23599,7 +23624,7 @@ function osMachineId() {
23599
23624
  return /"IOPlatformUUID" = "([^"]+)"/.exec(run("ioreg", ["-rd1", "-c", "IOPlatformExpertDevice"]) ?? "")?.[1] ?? null;
23600
23625
  case "linux":
23601
23626
  for (const path3 of ["/etc/machine-id", "/var/lib/dbus/machine-id"]) {
23602
- if (existsSync3(path3)) return readFileSync3(path3, "utf8").trim() || null;
23627
+ if (existsSync4(path3)) return readFileSync4(path3, "utf8").trim() || null;
23603
23628
  }
23604
23629
  return null;
23605
23630
  case "win32":
@@ -23611,18 +23636,18 @@ function osMachineId() {
23611
23636
  }
23612
23637
  }
23613
23638
  function persistedMachineId() {
23614
- const path3 = join3(dirname2(configPath()), "machine-id");
23615
- if (existsSync3(path3)) return readFileSync3(path3, "utf8").trim();
23639
+ const path3 = join4(dirname3(configPath()), "machine-id");
23640
+ if (existsSync4(path3)) return readFileSync4(path3, "utf8").trim();
23616
23641
  const id = randomUUID();
23617
- mkdirSync3(dirname2(path3), { recursive: true, mode: 448 });
23618
- writeFileSync3(path3, `${id}
23642
+ mkdirSync4(dirname3(path3), { recursive: true, mode: 448 });
23643
+ writeFileSync4(path3, `${id}
23619
23644
  `, { mode: 384 });
23620
23645
  return id;
23621
23646
  }
23622
23647
  function machineInfo() {
23623
23648
  const raw = `${osMachineId() ?? persistedMachineId()}:${userInfo().username}`;
23624
23649
  return {
23625
- fingerprint: createHash("sha256").update(raw).digest("hex"),
23650
+ fingerprint: createHash2("sha256").update(raw).digest("hex"),
23626
23651
  name: hostname3().replace(/\.local$/, ""),
23627
23652
  platform: platform()
23628
23653
  };
@@ -23750,7 +23775,7 @@ This repository is linked to a Slopware Tasks board. Use the \`slop\` CLI to kee
23750
23775
  - Before starting tracked work: \`slop task list\`, then \`slop task start <KEY>\`.
23751
23776
  - When it is finished and verified: \`slop task done <KEY>\`.
23752
23777
  - Descriptions are Markdown and are shown in full on the board. Write real ones: context, file paths, what done means. Pipe long ones in with \`-d -\`.
23753
- - Best: \`slop mcp install\` once, then use the slop MCP tools: \`claim_task\`, work, \`wait_for_message\` at every stopping point (it holds until someone writes to you), \`reply\`. Without MCP: \`slop session start <KEY>\` and \`slop session wait <KEY>\` in the background (the exit is the message; relaunch at once). Talk through threads: \`slop task ask <KEY> "\u2026"\` reaches whoever is live on that task, \`slop thread reply <id> "\u2026"\` answers, \`slop threads\` lists them; general threads are for people and for context. Method: \`slop skill slop-sessions\`.
23778
+ - Agents use the six slop MCP tools for everything on the board (\`slop mcp install\` once per machine): \`board\`, \`task\`, \`plan\`, \`session\`, \`threads\`, \`say\`, each with an \`action\`. Messages about your task reach you after your next tool call. The \`slop\` command is for people and scripts. Talk through threads: \`slop task ask <KEY> "\u2026"\` reaches whoever is live on that task, \`slop thread reply <id> "\u2026"\` answers, \`slop threads\` lists them; general threads are for people and for context. Method: \`slop skill slop-sessions\`.
23754
23779
  - Work that outlasts one session gets a plan on its task: \`slop task plan <KEY> -\` (one item per line), then \`slop task item note|done <KEY> <n>\` as you go; \`slop task items <KEY>\` shows where you are. \`slop skill slop-planning\` is the method; \`slop skills install\` puts the skills in this repository.
23755
23780
  - Everything is editable: \`slop task update <KEY> --title \u2026 --severity \u2026 --status \u2026 --assignee \u2026\`. Assign with \`slop task assign <KEY> <email|me|none>\`; see who exists with \`slop members\`. \`slop task list --mine\` shows your user's tasks.
23756
23781
  - Add \`--json\` to any command for machine-readable output. \`slop task --help\` lists everything.
@@ -23763,9 +23788,9 @@ function registerGuideCommand(program3) {
23763
23788
 
23764
23789
  // src/platform/git.ts
23765
23790
  import { execFileSync as execFileSync2 } from "child_process";
23766
- import { createHash as createHash2, randomUUID as randomUUID2 } from "crypto";
23767
- import { existsSync as existsSync4, readFileSync as readFileSync4, rmSync as rmSync2, writeFileSync as writeFileSync4 } from "fs";
23768
- import { basename, join as join4, resolve } from "path";
23791
+ import { createHash as createHash3, randomUUID as randomUUID2 } from "crypto";
23792
+ import { existsSync as existsSync5, readFileSync as readFileSync5, rmSync as rmSync3, writeFileSync as writeFileSync5 } from "fs";
23793
+ import { basename, join as join5, resolve } from "path";
23769
23794
  function git(args, cwd = process.cwd()) {
23770
23795
  try {
23771
23796
  return execFileSync2("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
@@ -23778,7 +23803,7 @@ function findRepo() {
23778
23803
  if (!commonDir) return null;
23779
23804
  const worktree = git(["rev-parse", "--show-toplevel"]);
23780
23805
  const root = basename(commonDir) === ".git" ? resolve(commonDir, "..") : worktree ?? commonDir;
23781
- return { root, worktree: worktree ?? root, linkPath: join4(commonDir, "slop.json") };
23806
+ return { root, worktree: worktree ?? root, linkPath: join5(commonDir, "slop.json") };
23782
23807
  }
23783
23808
  function requireRepo() {
23784
23809
  const repo = findRepo();
@@ -23787,17 +23812,17 @@ function requireRepo() {
23787
23812
  }
23788
23813
  function readLink(repo) {
23789
23814
  try {
23790
- return JSON.parse(readFileSync4(repo.linkPath, "utf8"));
23815
+ return JSON.parse(readFileSync5(repo.linkPath, "utf8"));
23791
23816
  } catch {
23792
23817
  return null;
23793
23818
  }
23794
23819
  }
23795
23820
  function writeLink(repo, link) {
23796
- writeFileSync4(repo.linkPath, `${JSON.stringify(link, null, 2)}
23821
+ writeFileSync5(repo.linkPath, `${JSON.stringify(link, null, 2)}
23797
23822
  `);
23798
23823
  }
23799
23824
  function removeLink(repo) {
23800
- if (existsSync4(repo.linkPath)) rmSync2(repo.linkPath);
23825
+ if (existsSync5(repo.linkPath)) rmSync3(repo.linkPath);
23801
23826
  }
23802
23827
  function normalizeRemote(url2) {
23803
23828
  const scp = /^[^@/]+@([^:/]+):(.+)$/.exec(url2);
@@ -23811,7 +23836,7 @@ function describeRepo(repo, existing) {
23811
23836
  let fingerprint;
23812
23837
  let localRepoId;
23813
23838
  if (roots) {
23814
- fingerprint = createHash2("sha256").update(roots.split("\n").sort().join("\n")).digest("hex");
23839
+ fingerprint = createHash3("sha256").update(roots.split("\n").sort().join("\n")).digest("hex");
23815
23840
  } else {
23816
23841
  localRepoId = existing?.localRepoId ?? randomUUID2();
23817
23842
  fingerprint = `local:${localRepoId}`;
@@ -23954,12 +23979,12 @@ linked to ${link.projectKey} ${link.projectName} (${link.baseUrl})`
23954
23979
  }
23955
23980
 
23956
23981
  // src/commands/mcp.ts
23957
- import { execFileSync as execFileSync3 } from "child_process";
23958
- import { createHash as createHash3 } from "crypto";
23959
- import { appendFileSync, existsSync as existsSync5, mkdirSync as mkdirSync4, readFileSync as readFileSync8, readdirSync, realpathSync, rmSync as rmSync3, writeFileSync as writeFileSync5 } from "fs";
23960
- import { dirname as dirname3 } from "path";
23982
+ import { execFileSync as execFileSync3, spawn as spawn2 } from "child_process";
23983
+ import { createHash as createHash4 } from "crypto";
23984
+ import { appendFileSync, existsSync as existsSync7, mkdirSync as mkdirSync6, readFileSync as readFileSync10, readdirSync, realpathSync as realpathSync2, rmSync as rmSync4, writeFileSync as writeFileSync7 } from "fs";
23985
+ import { dirname as dirname5 } from "path";
23961
23986
  import { homedir as homedir3 } from "os";
23962
- import { join as join5 } from "path";
23987
+ import { join as join7 } from "path";
23963
23988
 
23964
23989
  // src/platform/live.ts
23965
23990
  async function waitForMessage(api, projectId, taskKey, options = {}) {
@@ -23975,6 +24000,7 @@ async function waitForMessage(api, projectId, taskKey, options = {}) {
23975
24000
  const push = JSON.parse(String(event.data));
23976
24001
  if (push.type === "hello") {
23977
24002
  api.sessionId = push.session.id;
24003
+ rememberSession(process.cwd(), push.session.id);
23978
24004
  options.onHello?.(push.session);
23979
24005
  return;
23980
24006
  }
@@ -23989,6 +24015,7 @@ async function waitForMessage(api, projectId, taskKey, options = {}) {
23989
24015
  socket.addEventListener("close", (event) => done({ kind: "dropped", reason: event.reason || `code ${event.code}` }));
23990
24016
  socket.addEventListener("error", () => done({ kind: "dropped", reason: "socket error" }));
23991
24017
  });
24018
+ if (outcome.kind !== "message") rememberSession(process.cwd(), null);
23992
24019
  if (outcome.kind === "message" || outcome.kind === "timeout") {
23993
24020
  socket.close(1e3, outcome.kind === "message" ? "handled" : "timeout");
23994
24021
  await new Promise((resolve2) => {
@@ -24030,6 +24057,7 @@ var HeldSession = class {
24030
24057
  const push = JSON.parse(String(event.data));
24031
24058
  if (push.type === "hello") {
24032
24059
  this.api.sessionId = push.session.id;
24060
+ rememberSession(process.cwd(), push.session.id);
24033
24061
  return;
24034
24062
  }
24035
24063
  if (push.type === "end") {
@@ -24058,6 +24086,7 @@ var HeldSession = class {
24058
24086
  stop() {
24059
24087
  this.stopped = true;
24060
24088
  this.api.sessionId = null;
24089
+ rememberSession(process.cwd(), null);
24061
24090
  if (this.timer) clearTimeout(this.timer);
24062
24091
  this.socket?.close(1e3, "released");
24063
24092
  }
@@ -24120,11 +24149,11 @@ function serveMcp(tools2, info) {
24120
24149
  }
24121
24150
 
24122
24151
  // src/commands/tasks.ts
24123
- import { readFileSync as readFileSync6 } from "fs";
24152
+ import { readFileSync as readFileSync7 } from "fs";
24124
24153
 
24125
24154
  // src/commands/items.ts
24126
- import { readFileSync as readFileSync5 } from "fs";
24127
- var readText = (value) => value === "-" ? readFileSync5(0, "utf8").trim() : value;
24155
+ import { readFileSync as readFileSync6 } from "fs";
24156
+ var readText = (value) => value === "-" ? readFileSync6(0, "utf8").trim() : value;
24128
24157
  var path2 = (projectId, key) => `/projects/${projectId}/tasks/${encodeURIComponent(key)}/items`;
24129
24158
  function renderItems(items) {
24130
24159
  if (items.length === 0) return "No plan yet. Write one: slop task plan <KEY> -";
@@ -24151,7 +24180,7 @@ function registerItemCommands(task, withProject) {
24151
24180
  withProject(task.command("plan <key> [lines...]")).description('Append items to the plan, one per argument; "-" reads one per line from stdin (list markers are fine).').action(async (key, lines, options) => {
24152
24181
  const api = authedApi();
24153
24182
  const project = await resolveProject(api, options.project);
24154
- const source = lines.length === 1 && lines[0] === "-" ? readFileSync5(0, "utf8") : lines.join("\n");
24183
+ const source = lines.length === 1 && lines[0] === "-" ? readFileSync6(0, "utf8") : lines.join("\n");
24155
24184
  const titles = source.split("\n").map((line) => line.replace(/^\s*(?:[-*+]|\d+[.)])\s*(?:\[[ xX]\]\s*)?/, "").trim()).filter(Boolean);
24156
24185
  if (titles.length === 0) throw new CliError("Nothing to add", 'give items as arguments, or "-" and one per line on stdin');
24157
24186
  const items = await api.post(path2(project.id, key), { items: titles.map((title3) => ({ title: title3 })) });
@@ -24208,7 +24237,7 @@ function oneOf(value, allowed, what) {
24208
24237
  if (allowed.includes(normalized)) return normalized;
24209
24238
  throw new CliError(`Unknown ${what} "${value}"`, `use one of: ${allowed.join(", ")}`);
24210
24239
  }
24211
- var readDescription = (value) => value === "-" ? readFileSync6(0, "utf8").trim() : value;
24240
+ var readDescription = (value) => value === "-" ? readFileSync7(0, "utf8").trim() : value;
24212
24241
  var taskUrl = (baseUrl, projectId, task) => `${baseUrl}/projects/${projectId}?task=${task.key}`;
24213
24242
  var loadProject = (api, id) => api.get(`/projects/${id}`);
24214
24243
  function resolveStatus(project, ref) {
@@ -24358,9 +24387,55 @@ ${taskUrl(api.baseUrl, project.id, created)}`);
24358
24387
  return task;
24359
24388
  }
24360
24389
 
24390
+ // src/platform/update-check.ts
24391
+ import { existsSync as existsSync6, mkdirSync as mkdirSync5, readFileSync as readFileSync8, writeFileSync as writeFileSync6 } from "fs";
24392
+ import { dirname as dirname4, join as join6 } from "path";
24393
+ var CHECK_EVERY_MS = 60 * 60 * 1e3;
24394
+ var REGISTRY = "https://registry.npmjs.org/slopwaresystems/latest";
24395
+ var cacheFile = () => join6(dirname4(configPath()), "update-check.json");
24396
+ function readCache() {
24397
+ try {
24398
+ return JSON.parse(readFileSync8(cacheFile(), "utf8"));
24399
+ } catch {
24400
+ return { checkedAt: 0, latest: null };
24401
+ }
24402
+ }
24403
+ function writeCache(cache) {
24404
+ mkdirSync5(dirname4(cacheFile()), { recursive: true });
24405
+ writeFileSync6(cacheFile(), JSON.stringify(cache));
24406
+ }
24407
+ function isNewer(a, b) {
24408
+ const parse3 = (version2) => version2.split(".").map((part) => Number.parseInt(part, 10));
24409
+ const [x, y] = [parse3(a), parse3(b)];
24410
+ if (x.length !== 3 || y.length !== 3 || [...x, ...y].some((n) => Number.isNaN(n))) return false;
24411
+ for (let i = 0; i < 3; i += 1) {
24412
+ if (x[i] !== y[i]) return x[i] > y[i];
24413
+ }
24414
+ return false;
24415
+ }
24416
+ var inFlight = false;
24417
+ function refresh() {
24418
+ const cache = readCache();
24419
+ if (inFlight || Date.now() - cache.checkedAt < CHECK_EVERY_MS) return;
24420
+ inFlight = true;
24421
+ writeCache({ ...cache, checkedAt: Date.now() });
24422
+ fetch(REGISTRY, { signal: AbortSignal.timeout(4e3), headers: { accept: "application/json" } }).then(async (response) => response.ok ? (await response.json()).version ?? null : null).then((latest) => {
24423
+ if (latest) writeCache({ checkedAt: Date.now(), latest });
24424
+ }).catch(() => void 0).finally(() => {
24425
+ inFlight = false;
24426
+ });
24427
+ }
24428
+ function updateNotice(current) {
24429
+ refresh();
24430
+ const { latest } = readCache();
24431
+ if (!latest || !isNewer(latest, current)) return null;
24432
+ return `Update available: slopwaresystems ${latest} (this is ${current}). Ask your person to run: npm i -g slopwaresystems && slop mcp install`;
24433
+ }
24434
+ var updateChecksEnabled = () => !existsSync6(join6(dirname4(configPath()), "no-update-check")) && process.env.SLOP_NO_UPDATE_CHECK !== "1";
24435
+
24361
24436
  // src/commands/threads.ts
24362
- import { readFileSync as readFileSync7 } from "fs";
24363
- var readText2 = (value) => value === "-" ? readFileSync7(0, "utf8").trim() : value;
24437
+ import { readFileSync as readFileSync9 } from "fs";
24438
+ var readText2 = (value) => value === "-" ? readFileSync9(0, "utf8").trim() : value;
24364
24439
  var when = (iso) => iso.slice(0, 16).replace("T", " ");
24365
24440
  var who = (message) => `${message.author.name}${message.via === "agent" ? ` (agent${message.machine ? ` \xB7 ${message.machine}` : ""})` : ""}`;
24366
24441
  function threadLine(thread) {
@@ -24441,26 +24516,26 @@ var int2 = (description2) => ({ type: "integer", description: description2 });
24441
24516
  var schema = (properties, required2 = []) => ({ type: "object", properties, required: required2, additionalProperties: false });
24442
24517
  var PROJECT = str("Project key. Defaults to the project this repository is linked to.");
24443
24518
  var taskPath = (projectId, key) => `/projects/${projectId}/tasks/${encodeURIComponent(key)}`;
24444
- var inboxDir = () => join5(dirname3(configPath()), "inbox");
24445
- var realCwd = (cwd) => {
24519
+ var inboxDir = () => join7(dirname5(configPath()), "inbox");
24520
+ var realCwd2 = (cwd) => {
24446
24521
  try {
24447
- return realpathSync(cwd);
24522
+ return realpathSync2(cwd);
24448
24523
  } catch {
24449
24524
  return cwd;
24450
24525
  }
24451
24526
  };
24452
- var inboxKey = (cwd) => createHash3("sha256").update(realCwd(cwd)).digest("hex").slice(0, 16);
24453
- var inboxFile = (cwd) => join5(inboxDir(), `${inboxKey(cwd)}.jsonl`);
24527
+ var inboxKey = (cwd) => createHash4("sha256").update(realCwd2(cwd)).digest("hex").slice(0, 16);
24528
+ var inboxFile = (cwd) => join7(inboxDir(), `${inboxKey(cwd)}.jsonl`);
24454
24529
  function inboxWrite(cwd, push) {
24455
- mkdirSync4(inboxDir(), { recursive: true });
24530
+ mkdirSync6(inboxDir(), { recursive: true });
24456
24531
  appendFileSync(inboxFile(cwd), `${JSON.stringify(push)}
24457
24532
  `);
24458
24533
  }
24459
24534
  function inboxDrain(cwd) {
24460
24535
  const path3 = inboxFile(cwd);
24461
- if (!existsSync5(path3)) return [];
24462
- const lines = readFileSync8(path3, "utf8").split("\n").filter(Boolean);
24463
- rmSync3(path3, { force: true });
24536
+ if (!existsSync7(path3)) return [];
24537
+ const lines = readFileSync10(path3, "utf8").split("\n").filter(Boolean);
24538
+ rmSync4(path3, { force: true });
24464
24539
  return lines.map((line) => JSON.parse(line));
24465
24540
  }
24466
24541
  async function digest(api, projectId, taskKey) {
@@ -24476,7 +24551,7 @@ async function digest(api, projectId, taskKey) {
24476
24551
  if (general.length) lines.push("Unread general threads (context; nobody has to answer):", ...general.map(threadLine));
24477
24552
  return lines.length ? lines.join("\n") : "Nothing unread.";
24478
24553
  }
24479
- function tools() {
24554
+ function handlers() {
24480
24555
  const api = authedApi();
24481
24556
  const resolveOnly = (args) => resolveProject(api, args.project);
24482
24557
  let held = null;
@@ -24643,6 +24718,160 @@ ${task.notes}` : ""].filter(Boolean).join("\n");
24643
24718
  return `Item ${String(args.item)} done.`;
24644
24719
  }
24645
24720
  },
24721
+ /* ---------- the board itself: everything a person can do in a project ---------- */
24722
+ {
24723
+ name: "list_tasks",
24724
+ description: "Tasks on the board (done ones hidden unless `all`). Key, status, severity, assignee, title.",
24725
+ inputSchema: schema({ status: str("Only this column (key or label)"), mine: { type: "boolean", description: "Only tasks assigned to you" }, all: { type: "boolean", description: "Include done tasks" }, archived: { type: "boolean", description: "Archived tasks instead" }, project: PROJECT }),
24726
+ run: async (args) => {
24727
+ const target = await loadProject(api, (await project(args)).id);
24728
+ const list = await api.get(`/projects/${target.id}/tasks${args.archived ? "?archived=1" : ""}`);
24729
+ const me = args.mine ? (await api.get("/me")).user.id : null;
24730
+ const doneKeys = new Set(target.statuses.filter((status) => status.category === "done").map((status) => status.key));
24731
+ const only = args.status ? resolveStatus(target, String(args.status)) : null;
24732
+ const rows = list.filter((task) => (args.all || args.archived || !doneKeys.has(task.status)) && (!me || task.assignee?.userId === me) && (!only || task.status === only));
24733
+ return rows.length ? rows.map((task) => `${task.key} ${target.statuses.find((status) => status.key === task.status)?.label ?? task.status} ${task.severity} ${task.assignee?.name ?? "-"} ${task.title}${task.progress.total ? ` (${task.progress.done}/${task.progress.total})` : ""}`).join("\n") : "No tasks.";
24734
+ }
24735
+ },
24736
+ {
24737
+ name: "create_task",
24738
+ description: "Create a task. The description is Markdown; write a real one: context, file paths, what done means.",
24739
+ inputSchema: schema({ title: str("Short, imperative"), description: str("Markdown"), severity: str(`One of ${TASK_SEVERITIES.join(", ")} (default medium)`), status: str("A column key or label (default: the first todo column)"), assignee: str("An email, a name, or me"), project: PROJECT }, ["title"]),
24740
+ run: async (args) => {
24741
+ const target = await loadProject(api, (await project(args)).id);
24742
+ const task = await api.post(`/projects/${target.id}/tasks`, {
24743
+ title: String(args.title),
24744
+ description: args.description ?? "",
24745
+ severity: args.severity ?? "medium",
24746
+ status: resolveStatus(target, args.column ?? args.status),
24747
+ assigneeId: await resolveAssignee(api, target.id, args.assignee) ?? null
24748
+ });
24749
+ return `Created ${task.key} ${task.title}`;
24750
+ }
24751
+ },
24752
+ {
24753
+ name: "update_task",
24754
+ description: "Change a task's title, description, notes, severity, column or assignee. Only the fields you give change.",
24755
+ inputSchema: schema({ task: str("Task key"), title: str(""), description: str("Markdown, replaced whole"), notes: str("The scratchpad, replaced whole"), severity: str(TASK_SEVERITIES.join(" | ")), status: str("A column key or label"), assignee: str("An email, a name, me, or none"), project: PROJECT }, ["task"]),
24756
+ run: async (args) => {
24757
+ const target = await loadProject(api, (await project(args)).id);
24758
+ const patch = {
24759
+ title: args.title,
24760
+ description: args.description,
24761
+ notes: args.notes,
24762
+ severity: args.severity,
24763
+ status: resolveStatus(target, args.column ?? args.status),
24764
+ assigneeId: await resolveAssignee(api, target.id, args.assignee)
24765
+ };
24766
+ if (Object.values(patch).every((value) => value === void 0)) throw new Error("Nothing to change");
24767
+ const task = await api.patch(taskPath(target.id, String(args.task)), patch);
24768
+ return `Updated ${task.key} ${task.title} [${task.status}, ${task.severity}, ${task.assignee?.name ?? "unassigned"}]`;
24769
+ }
24770
+ },
24771
+ {
24772
+ name: "move_task",
24773
+ description: "Move a task to another column, by key or label.",
24774
+ inputSchema: schema({ task: str("Task key"), column: str("Column key or label"), project: PROJECT }, ["task", "column"]),
24775
+ run: async (args) => {
24776
+ const target = await loadProject(api, (await project(args)).id);
24777
+ const task = await api.patch(taskPath(target.id, String(args.task)), { status: resolveStatus(target, String(args.column ?? args.status)) });
24778
+ return `${task.key} is in ${target.statuses.find((status) => status.key === task.status)?.label ?? task.status}.`;
24779
+ }
24780
+ },
24781
+ {
24782
+ name: "link_task",
24783
+ description: "Attach a URL to a task: a pull request, a design, a log. GitHub PRs are recognised.",
24784
+ inputSchema: schema({ task: str("Task key"), url: str("The URL"), title: str("What to call it (default: from the URL)"), project: PROJECT }, ["task", "url"]),
24785
+ run: async (args) => {
24786
+ const target = await project(args);
24787
+ const task = await api.post(`${taskPath(target.id, String(args.task))}/links`, { url: String(args.url), title: args.title });
24788
+ return `Linked. ${task.key} now has ${task.links.length} link(s).`;
24789
+ }
24790
+ },
24791
+ {
24792
+ name: "archive_task",
24793
+ description: "Take a task off the board (its history is kept). `restore` puts it back.",
24794
+ inputSchema: schema({ task: str("Task key"), restore: { type: "boolean", description: "Put an archived task back" }, project: PROJECT }, ["task"]),
24795
+ run: async (args) => {
24796
+ const target = await project(args);
24797
+ if (args.action === "restore") args = { ...args, restore: true };
24798
+ const task = await api.post(`${taskPath(target.id, String(args.task))}/${args.restore ? "unarchive" : "archive"}`);
24799
+ return args.restore ? `${task.key} is back on the board.` : `${task.key} is archived.`;
24800
+ }
24801
+ },
24802
+ {
24803
+ name: "task_history",
24804
+ description: "Everything that happened to a task, oldest first.",
24805
+ inputSchema: schema({ task: str("Task key"), project: PROJECT }, ["task"]),
24806
+ run: async (args) => {
24807
+ const target = await project(args);
24808
+ const events = await api.get(`${taskPath(target.id, String(args.task))}/events`);
24809
+ return events.length ? events.map((record2) => `${record2.createdAt.slice(0, 16).replace("T", " ")} ${record2.actor.name} ${JSON.stringify(record2.event)}`).join("\n") : "No history yet.";
24810
+ }
24811
+ },
24812
+ {
24813
+ name: "plan_task",
24814
+ description: "Append plan items to a task, in order. Use this to break long work into checkable steps; keep each item's note current as you go.",
24815
+ inputSchema: schema({ task: str("Task key"), items: { type: "array", items: { type: "string" }, description: "Item titles, in order" }, project: PROJECT }, ["task", "items"]),
24816
+ run: async (args) => {
24817
+ const target = await project(args);
24818
+ const items = await api.post(`${taskPath(target.id, String(args.task))}/items`, { items: args.items.map((title3) => ({ title: title3 })) });
24819
+ return items.map((item, index) => `${index + 1}. [${item.done ? "x" : " "}] ${item.title}`).join("\n");
24820
+ }
24821
+ },
24822
+ {
24823
+ name: "edit_item",
24824
+ description: "Retitle, reopen or remove a plan item (numbered as in show_task).",
24825
+ inputSchema: schema({ task: str("Task key"), item: int2("Item number"), title: str("New title"), reopen: { type: "boolean", description: "Untick it" }, remove: { type: "boolean", description: "Remove it" }, project: PROJECT }, ["task", "item"]),
24826
+ run: async (args) => {
24827
+ const target = await project(args);
24828
+ const items = await api.get(`${taskPath(target.id, String(args.task))}/items`);
24829
+ const item = items[Number(args.item) - 1];
24830
+ if (!item) throw new Error(`No item ${String(args.item)}`);
24831
+ if (args.action === "remove") args = { ...args, remove: true };
24832
+ if (args.action === "reopen") args = { ...args, reopen: true };
24833
+ if (args.remove) {
24834
+ await api.delete(`${taskPath(target.id, String(args.task))}/items/${item.id}`);
24835
+ return `Item ${String(args.item)} removed.`;
24836
+ }
24837
+ await api.patch(`${taskPath(target.id, String(args.task))}/items/${item.id}`, { title: args.title, done: args.reopen ? false : void 0 });
24838
+ return `Item ${String(args.item)} updated.`;
24839
+ }
24840
+ },
24841
+ {
24842
+ name: "columns",
24843
+ description: "The board's columns in order: key, label, and what each counts as (backlog, todo, in_progress, done).",
24844
+ inputSchema: schema({ project: PROJECT }),
24845
+ run: async (args) => {
24846
+ const target = await loadProject(api, (await project(args)).id);
24847
+ return target.statuses.map((status) => `${status.key} ${status.label} (${status.category})`).join("\n");
24848
+ }
24849
+ },
24850
+ {
24851
+ name: "members",
24852
+ description: "Who can be assigned: name, email, role.",
24853
+ inputSchema: schema({ project: PROJECT }),
24854
+ run: async (args) => {
24855
+ const target = await loadProject(api, (await project(args)).id);
24856
+ const { members: members2 } = await api.get(`/orgs/${target.orgId}/members`);
24857
+ return members2.map((member) => `${member.name} ${member.email} ${member.role}`).join("\n");
24858
+ }
24859
+ },
24860
+ {
24861
+ name: "projects",
24862
+ description: "Every project you can reach, and which one this repository is linked to.",
24863
+ inputSchema: schema({}),
24864
+ run: async () => {
24865
+ const list = await api.get("/projects");
24866
+ let linked = null;
24867
+ try {
24868
+ linked = (await resolveOnly({})).key;
24869
+ } catch {
24870
+ linked = null;
24871
+ }
24872
+ return list.map((candidate) => `${candidate.key === linked ? "* " : " "}${candidate.key} ${candidate.name} (${candidate.openTaskCount} open)`).join("\n") + (linked ? "" : "\n\nThis repository is not linked: slop link <KEY>");
24873
+ }
24874
+ },
24646
24875
  {
24647
24876
  name: "finish_task",
24648
24877
  description: "Move a task to the first done column.",
@@ -24658,7 +24887,7 @@ ${task.notes}` : ""].filter(Boolean).join("\n");
24658
24887
  }
24659
24888
  function readJson(path3) {
24660
24889
  try {
24661
- return JSON.parse(readFileSync8(path3, "utf8"));
24890
+ return JSON.parse(readFileSync10(path3, "utf8"));
24662
24891
  } catch {
24663
24892
  return {};
24664
24893
  }
@@ -24671,13 +24900,13 @@ function installClaude() {
24671
24900
  } catch {
24672
24901
  done.push("Claude Code: run `claude mcp add --scope user slop -- slop mcp` (the claude command was not found here)");
24673
24902
  }
24674
- const dir = join5(homedir3(), ".claude");
24675
- const path3 = join5(dir, "settings.json");
24676
- mkdirSync4(dir, { recursive: true });
24903
+ const dir = join7(homedir3(), ".claude");
24904
+ const path3 = join7(dir, "settings.json");
24905
+ mkdirSync6(dir, { recursive: true });
24677
24906
  const settings = readJson(path3);
24678
24907
  settings.hooks = mergeHooks(settings.hooks ?? {});
24679
24908
  settings.env = { ...settings.env ?? {}, MCP_TOOL_TIMEOUT: "1800000" };
24680
- writeFileSync5(path3, `${JSON.stringify(settings, null, 2)}
24909
+ writeFileSync7(path3, `${JSON.stringify(settings, null, 2)}
24681
24910
  `);
24682
24911
  done.push(`Claude Code: PostToolUse and Stop hooks and MCP_TOOL_TIMEOUT written to ${path3}`);
24683
24912
  return done;
@@ -24695,27 +24924,79 @@ function mergeHooks(hooks) {
24695
24924
  }
24696
24925
  function installCodex() {
24697
24926
  const done = [];
24698
- const dir = join5(homedir3(), ".codex");
24699
- mkdirSync4(dir, { recursive: true });
24700
- const configPath2 = join5(dir, "config.toml");
24701
- const current = existsSync5(configPath2) ? readFileSync8(configPath2, "utf8") : "";
24927
+ const dir = join7(homedir3(), ".codex");
24928
+ mkdirSync6(dir, { recursive: true });
24929
+ const configPath2 = join7(dir, "config.toml");
24930
+ const current = existsSync7(configPath2) ? readFileSync10(configPath2, "utf8") : "";
24702
24931
  if (/^\[mcp_servers\.slop\]/m.test(current)) done.push(`Codex: MCP server already in ${configPath2}`);
24703
24932
  else {
24704
- writeFileSync5(configPath2, `${current.trimEnd()}${current.trim() ? "\n\n" : ""}[mcp_servers.slop]
24933
+ writeFileSync7(configPath2, `${current.trimEnd()}${current.trim() ? "\n\n" : ""}[mcp_servers.slop]
24705
24934
  command = "slop"
24706
24935
  args = ["mcp"]
24707
24936
  tool_timeout_sec = 1800
24708
24937
  `);
24709
24938
  done.push(`Codex: MCP server "slop" added to ${configPath2}`);
24710
24939
  }
24711
- const hooksPath = join5(dir, "hooks.json");
24940
+ const hooksPath = join7(dir, "hooks.json");
24712
24941
  const file2 = readJson(hooksPath);
24713
24942
  file2.hooks = mergeHooks(file2.hooks ?? {});
24714
- writeFileSync5(hooksPath, `${JSON.stringify(file2, null, 2)}
24943
+ writeFileSync7(hooksPath, `${JSON.stringify(file2, null, 2)}
24715
24944
  `);
24716
- done.push(`Codex: PostToolUse and Stop hooks written to ${hooksPath}. Codex asks you to trust new hooks once (/hooks in a session).`);
24945
+ done.push(`Codex: PostToolUse and Stop hooks written to ${hooksPath}`);
24717
24946
  return done;
24718
24947
  }
24948
+ async function trustCodexHooks() {
24949
+ const server = spawn2("codex", ["app-server"], { stdio: ["pipe", "pipe", "ignore"] });
24950
+ let buffer = "";
24951
+ const pending = /* @__PURE__ */ new Map();
24952
+ server.stdout.on("data", (chunk) => {
24953
+ buffer += String(chunk);
24954
+ let index;
24955
+ while ((index = buffer.indexOf("\n")) >= 0) {
24956
+ const line = buffer.slice(0, index);
24957
+ buffer = buffer.slice(index + 1);
24958
+ if (!line.trim()) continue;
24959
+ try {
24960
+ const message = JSON.parse(line);
24961
+ if (typeof message.id === "number") pending.get(message.id)?.(message);
24962
+ } catch {
24963
+ }
24964
+ }
24965
+ });
24966
+ let nextId = 1;
24967
+ const call = (method, params) => new Promise((resolve2, reject) => {
24968
+ const id = nextId++;
24969
+ const timer = setTimeout(() => {
24970
+ pending.delete(id);
24971
+ reject(new Error(`${method} timed out`));
24972
+ }, 15e3);
24973
+ pending.set(id, (message) => {
24974
+ clearTimeout(timer);
24975
+ if (message.error) reject(new Error(message.error.message ?? `${method} failed`));
24976
+ else resolve2(message.result);
24977
+ });
24978
+ server.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id, method, params })}
24979
+ `);
24980
+ });
24981
+ const notify = (method, params) => server.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", method, params })}
24982
+ `);
24983
+ try {
24984
+ await call("initialize", { clientInfo: { name: "slop", version: "0.3.0" }, capabilities: { experimentalApi: true } });
24985
+ notify("initialized", {});
24986
+ const listed = await call("hooks/list", { cwds: [process.cwd()] });
24987
+ const ours = listed.data.flatMap((entry) => entry.hooks).filter((hook) => hook.command === "slop hook tick" || hook.command === "slop hook stop");
24988
+ if (ours.length === 0) return "Codex: could not find the slop hooks in its list; trust them once with /hooks in a Codex session";
24989
+ const untrusted = ours.filter((hook) => hook.trustStatus !== "trusted");
24990
+ if (untrusted.length === 0) return "Codex: the slop hooks are already trusted";
24991
+ const value = Object.fromEntries(untrusted.map((hook) => [hook.key, { trusted_hash: hook.currentHash }]));
24992
+ await call("config/batchWrite", { edits: [{ keyPath: "hooks.state", value, mergeStrategy: "upsert" }], reloadUserConfig: true });
24993
+ return `Codex: trusted ${untrusted.length} slop hook(s), so it will not ask`;
24994
+ } catch (error62) {
24995
+ return `Codex: could not trust the hooks automatically (${error62 instanceof Error ? error62.message : String(error62)}); trust them once with /hooks in a Codex session`;
24996
+ } finally {
24997
+ server.kill();
24998
+ }
24999
+ }
24719
25000
  async function hookCwd() {
24720
25001
  const input2 = await new Promise((resolve2) => {
24721
25002
  let data = "";
@@ -24769,26 +25050,86 @@ ${parts.join("\n\n")}` })}
24769
25050
  ${parts.join("\n")}` })}
24770
25051
  `);
24771
25052
  }
24772
- function withInbox(list) {
24773
- return list.map(
24774
- (tool) => tool.name === "check_messages" || tool.name === "wait_for_message" ? tool : {
24775
- ...tool,
24776
- run: async (args) => {
24777
- const result = await tool.run(args);
25053
+ function tools() {
25054
+ const fine = handlers();
25055
+ const run2 = (name, args) => {
25056
+ const handler = fine.find((candidate) => candidate.name === name);
25057
+ if (!handler) throw new Error(`No such action`);
25058
+ return handler.run(args);
25059
+ };
25060
+ const group = (name, description2, actions, properties) => ({
25061
+ name,
25062
+ description: description2,
25063
+ inputSchema: schema({ action: { type: "string", enum: Object.keys(actions) }, ...properties, project: PROJECT }, ["action"]),
25064
+ run: (args) => {
25065
+ const target = actions[String(args.action)];
25066
+ if (!target) throw new Error(`action must be one of: ${Object.keys(actions).join(", ")}`);
25067
+ return run2(target, args);
25068
+ }
25069
+ });
25070
+ return [
25071
+ group(
25072
+ "board",
25073
+ "The project: tasks on the board, columns, members, projects.",
25074
+ { list: "list_tasks", columns: "columns", members: "members", projects: "projects" },
25075
+ { status: str("list: only this column"), mine: { type: "boolean" }, all: { type: "boolean", description: "list: include done" }, archived: { type: "boolean" } }
25076
+ ),
25077
+ group(
25078
+ "task",
25079
+ "One task. show; create; update (only given fields change); move; link; archive/restore; history; finish (first done column, releases your session).",
25080
+ { show: "show_task", create: "create_task", update: "update_task", move: "move_task", link: "link_task", archive: "archive_task", restore: "archive_task", history: "task_history", finish: "finish_task" },
25081
+ { task: str("Key, e.g. WEB-12 (all but create)"), title: str(""), description: str("Markdown"), notes: str("scratchpad, Markdown"), severity: str("low|medium|high|critical"), column: str("column key or label"), assignee: str("email, name, me, or none"), url: str("link: the URL") }
25082
+ ),
25083
+ group(
25084
+ "plan",
25085
+ "The task's plan: numbered items, each with a note that is its current state. add items; note/done/reopen/rename/remove one by number.",
25086
+ { add: "plan_task", note: "note_item", done: "finish_item", reopen: "edit_item", rename: "edit_item", remove: "edit_item" },
25087
+ { task: str("Task key"), items: { type: "array", items: { type: "string" }, description: "add: titles in order" }, item: int2("item number"), note: str("note: what is true now"), title: str("rename: new title") }
25088
+ ),
25089
+ group(
25090
+ "session",
25091
+ "Being live. claim: take a task, go live on it, get what is unread (messages then reach you after any tool call). release; finish is on task. who: live sessions. check: unread now. wait: block for the next message (optional).",
25092
+ { claim: "claim_task", release: "release_task", who: "sessions", check: "check_messages", wait: "wait_for_message" },
25093
+ { task: str("Task key"), minutes: int2("wait: give up after (default 10)") }
25094
+ ),
25095
+ group("threads", "Talk between agents and people. list threads; read one (marks it read).", { list: "threads", read: "thread" }, { task: str("list: about this task"), unread: { type: "boolean" }, id: str("read: thread id") }),
25096
+ group(
25097
+ "say",
25098
+ "post: start a thread (with task: reaches whoever is live on it; without: general, for people). reply: in a thread; reaches its task's live sessions and its participants.",
25099
+ { post: "post", reply: "reply" },
25100
+ { subject: str("post"), body: str("Markdown"), task: str("post: about this task"), thread: str("reply: thread id") }
25101
+ )
25102
+ ];
25103
+ }
25104
+ function withInbox(list, version2) {
25105
+ return list.map((tool) => ({
25106
+ ...tool,
25107
+ run: async (args) => {
25108
+ const result = await tool.run(args);
25109
+ const parts = [result];
25110
+ if (!(tool.name === "session" && (args.action === "check" || args.action === "wait"))) {
24778
25111
  const arrived = inboxDrain(process.cwd());
24779
- return arrived.length ? `${result}
24780
-
24781
- --- Messages arrived while you worked ---
24782
- ${arrived.map(pushBlock).join("\n\n")}` : result;
25112
+ if (arrived.length) parts.push(`--- Messages arrived while you worked ---
25113
+ ${arrived.map(pushBlock).join("\n\n")}`);
24783
25114
  }
25115
+ const notice = updateChecksEnabled() ? updateNotice(version2) : null;
25116
+ if (notice) parts.push(notice);
25117
+ return parts.join("\n\n");
24784
25118
  }
24785
- );
25119
+ }));
24786
25120
  }
24787
25121
  function registerMcpCommands(program3, version2) {
24788
25122
  const mcp = program3.command("mcp").description("The MCP server: the board and its threads as tools for Claude Code, Codex and any MCP client. Holds your live session while it runs.");
24789
- mcp.command("serve", { isDefault: true }).description("Serve MCP over stdio (what the harness runs).").action(() => serveMcp(withInbox(tools()), { name: "slop", version: version2 }));
24790
- mcp.command("install").description("Register the server and the two hooks with Claude Code and Codex on this machine.").action(() => {
25123
+ mcp.command("serve", { isDefault: true }).description("Serve MCP over stdio (what the harness runs).").action(() => serveMcp(withInbox(tools(), version2), { name: "slop", version: version2 }));
25124
+ mcp.command("install").description("Register the server and the two hooks with Claude Code and Codex on this machine.").action(async () => {
24791
25125
  const lines = [...installClaude(), ...installCodex()];
25126
+ let codexPresent = true;
25127
+ try {
25128
+ execFileSync3("codex", ["--version"], { stdio: "ignore" });
25129
+ } catch {
25130
+ codexPresent = false;
25131
+ }
25132
+ if (codexPresent) lines.push(await trustCodexHooks());
24792
25133
  emit({ done: lines }, () => lines.join("\n"));
24793
25134
  });
24794
25135
  const hook = program3.command("hook").description("Harness hooks (installed by slop mcp install).");
@@ -24797,7 +25138,7 @@ function registerMcpCommands(program3, version2) {
24797
25138
  }
24798
25139
 
24799
25140
  // src/commands/sessions.ts
24800
- import { readFileSync as readFileSync9 } from "fs";
25141
+ import { readFileSync as readFileSync11 } from "fs";
24801
25142
  var taskPath2 = (projectId, key) => `/projects/${projectId}/tasks/${encodeURIComponent(key)}`;
24802
25143
  var sessionLine = (session) => [session.taskKey ?? "(no task)", session.user.name, session.machine.name, session.startedAt];
24803
25144
  function registerSessionCommands(program3, task) {
@@ -24881,13 +25222,13 @@ ${written.map((path3) => ` ${path3}`).join("\n")}`);
24881
25222
  }
24882
25223
 
24883
25224
  // src/index.ts
24884
- var program2 = new Command().name("slop").description("Slopware Tasks from the terminal. Everything a person can do on the board, an agent can do here.").version("0.2.3").option("--json", "machine-readable output: JSON on stdout, errors as JSON on stderr").hook("preAction", (_, action) => setJsonMode(Boolean(action.optsWithGlobals().json))).showHelpAfterError("(run with --help for usage)");
25225
+ var program2 = new Command().name("slop").description("Slopware Tasks from the terminal. Everything a person can do on the board, an agent can do here.").version("0.3.0").option("--json", "machine-readable output: JSON on stdout, errors as JSON on stderr").hook("preAction", (_, action) => setJsonMode(Boolean(action.optsWithGlobals().json))).showHelpAfterError("(run with --help for usage)");
24885
25226
  registerAuthCommands(program2);
24886
25227
  registerProjectCommands(program2);
24887
25228
  var taskCommand = registerTaskCommands(program2);
24888
25229
  registerSessionCommands(program2, taskCommand);
24889
25230
  registerThreadCommands(program2, taskCommand);
24890
- registerMcpCommands(program2, "0.2.3");
25231
+ registerMcpCommands(program2, "0.3.0");
24891
25232
  registerGuideCommand(program2);
24892
25233
  registerSkillCommands(program2);
24893
25234
  program2.parseAsync().catch(fail);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "slopwaresystems",
3
- "version": "0.2.3",
3
+ "version": "0.3.0",
4
4
  "private": false,
5
5
  "description": "slop: the Slopware Tasks board from the terminal, for people and coding agents.",
6
6
  "type": "module",