slopwaresystems 0.1.1 → 0.2.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 (3) hide show
  1. package/README.md +1 -1
  2. package/dist/index.js +217 -76
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -4,7 +4,7 @@ The [Slopware Tasks](https://tasks.slopware.systems) board from the terminal. Bu
4
4
 
5
5
  ```bash
6
6
  npm i -g slopwaresystems
7
- slop login # approve the machine in your browser
7
+ slop login # approve the machine in your browser (on a VM or over SSH: open the printed link on any device)
8
8
  cd your-repo && slop link WEB
9
9
  slop task create "Retry storms on 429" -s high -d -
10
10
  slop skills install # puts the agent skills into .claude/skills/
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\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\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 task ask <KEY> "\u2026" # say something to whoever is live on it (also on the timeline)\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] [--theme <theme>]\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 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\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';
3383
3383
 
3384
3384
  // skills/slop-planning.md
3385
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';
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 while working (a live session), sees who else is live, and talks to them on the task. Load when starting work on a task, when asked to check on other agents, or when a `slop session wait` background task finishes.\n---\n\n# Sessions: being on a task\n\nA **session** is you, live on one task. It exists while `slop session wait` is running. When that process ends (you exit, or your thread closes), the session ends with it. Sessions are visible to everyone (`slop sessions`, the board) and are advisory: they say who is working, they do not lock anything.\n\n## The loop\n\n1. **Claim:** `slop session start <KEY>`. Assigns the task to you and moves it to in progress.\n2. **Go live:** run `slop session wait <KEY>` **in the background** (Claude Code: a background Bash task; Codex: a background command). It blocks until someone says something on the task, prints it, and exits.\n3. **Work.** Keep the plan's notes current (`slop-planning`).\n4. **When the wait exits with a message:** read it, act or answer with `slop task ask <KEY> \"\u2026\"`, then start `slop session wait <KEY>` in the background again. While no wait is running you are not live.\n5. **Finish:** `slop task done <KEY>`, then `slop session end <KEY>` (or just let the wait exit).\n\nExit codes of `wait`: 0 a message was printed; 2 the session was ended on purpose (someone ran `slop session end`); 3 `--timeout` elapsed with nothing said; 4 the connection dropped. On 2 do not relaunch. On 3 or 4 relaunch if you are still working.\n\n## Talking\n\n- `slop task ask <KEY> \"Which schema did you settle on?\"` reaches whoever is live on that task right now and lands on the task's timeline either way.\n- `slop sessions` shows who is live where. `slop task history <KEY>` shows what was said before you arrived.\n- Keep it to questions and answers the other agent needs; state goes in the plan's notes, decisions in the description.\n\n## Etiquette\n\n- One live session per agent. Do not `session start` a task that has someone else's live session; ask them first.\n- Answer asks promptly: the other agent is blocked on you.\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 on one task. It exists while `slop session wait` is running: when that process ends (you exit, or your thread closes), the session ends with it. 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** reaches your waiter at once. Nothing else does: another task\'s thread, or a general one, never interrupts you.\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\n\n1. **Claim:** `slop session start <KEY>`. Assigns the task to you, moves it to in progress, and prints what is waiting for you. Read it before touching anything.\n2. **Go live:** run `slop session wait <KEY>` **in the background** (Claude Code: a background Bash task; Codex: a background command). It blocks until someone writes in a thread about your task, prints the message with its thread id, and exits. **The exit is the message.** If your harness can watch a stream instead, `slop session wait <KEY> --follow` stays up and prints each message as it comes.\n3. **Work.** Keep the plan\'s notes current (`slop-planning`).\n4. **When the wait exits with a message:** read it, act or answer with `slop thread reply <id> "\u2026"`, then start `slop session wait <KEY>` in the background **immediately**. Coming back within two minutes continues the same session; not coming back means you have gone dark.\n5. **Finish:** `slop task done <KEY>`, then `slop session end <KEY>` (or just let the wait exit).\n\nExit codes of `wait`: 0 a message was printed; 2 the session was ended on purpose (someone ran `slop session end`); 3 `--timeout` elapsed with nothing said; 4 the connection dropped. On 2 do not relaunch. On 3 or 4 relaunch if you are still working.\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. Do not `session start` a task that has someone else\'s live session; 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, 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:** `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';
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 };
@@ -17722,7 +17722,7 @@ function generateRecordCheck(doc, ctx, schema, accessor) {
17722
17722
  }, `return INVALID;`);
17723
17723
  return outputVar;
17724
17724
  }
17725
- function emitOwnKeys(doc, ctx, accessor, kVar, body, onSymbol) {
17725
+ function emitOwnKeys(doc, ctx, accessor, kVar, body2, onSymbol) {
17726
17726
  const propIsEnumerableConst = addConstant(ctx, Object.prototype.propertyIsEnumerable);
17727
17727
  const symsVar = newVar(ctx);
17728
17728
  const keysVar = newVar(ctx);
@@ -17733,7 +17733,7 @@ function emitOwnKeys(doc, ctx, accessor, kVar, body, onSymbol) {
17733
17733
  doc.indented((d) => {
17734
17734
  d.write(`const ${kVar} = ${keysVar}[${iVar}];`);
17735
17735
  d.write(`if (${kVar} === "__proto__" || !${propIsEnumerableConst}.call(${accessor}, ${kVar})) continue;`);
17736
- body(d);
17736
+ body2(d);
17737
17737
  });
17738
17738
  doc.write(`}`);
17739
17739
  doc.write(`for (let ${iVar} = 0; ${iVar} < ${symsVar}.length; ${iVar}++) {`);
@@ -17743,7 +17743,7 @@ function emitOwnKeys(doc, ctx, accessor, kVar, body, onSymbol) {
17743
17743
  if (onSymbol)
17744
17744
  d.write(onSymbol);
17745
17745
  else
17746
- body(d);
17746
+ body2(d);
17747
17747
  });
17748
17748
  doc.write(`}`);
17749
17749
  }
@@ -23207,9 +23207,21 @@ var Invitation = external_exports.object({
23207
23207
  role: OrgRole,
23208
23208
  createdAt: Timestamp
23209
23209
  });
23210
+ var InviteLink = external_exports.object({
23211
+ id: Id,
23212
+ role: OrgRole,
23213
+ createdAt: Timestamp,
23214
+ expiresAt: Timestamp.nullable(),
23215
+ uses: external_exports.number()
23216
+ });
23217
+ var CreateInviteLinkInput = external_exports.object({ role: OrgRole.default("member"), expiresInHours: external_exports.number().int().min(1).max(24 * 30).nullable().default(24) });
23218
+ var CreatedInviteLink = InviteLink.extend({ url: external_exports.string() });
23219
+ var JoinPreview = external_exports.object({ org: external_exports.object({ id: Id, name: external_exports.string() }), role: OrgRole, alreadyMember: external_exports.boolean() });
23210
23220
  var MembersResult = external_exports.object({
23211
23221
  members: external_exports.array(Member),
23212
- invitations: external_exports.array(Invitation)
23222
+ invitations: external_exports.array(Invitation),
23223
+ /** Active links only. Admins see them; members get an empty list. */
23224
+ links: external_exports.array(InviteLink)
23213
23225
  });
23214
23226
  var InviteMemberInput = external_exports.object({ email: Email, role: OrgRole.default("member") });
23215
23227
  var UpdateMemberInput = external_exports.object({ role: OrgRole });
@@ -23241,8 +23253,6 @@ function findStatus(statuses, ref) {
23241
23253
 
23242
23254
  // ../contract/src/work/projects.ts
23243
23255
  var ProjectKey = external_exports.string().trim().toUpperCase().pipe(external_exports.string().regex(/^[A-Z][A-Z0-9]{1,7}$/, "2\u20138 letters or digits, starting with a letter"));
23244
- var PROJECT_THEMES = ["slate", "stone", "zinc", "mist", "sand", "rose", "violet", "emerald"];
23245
- var ProjectTheme = external_exports.enum(PROJECT_THEMES);
23246
23256
  var TaskCounts = external_exports.record(external_exports.string(), external_exports.number());
23247
23257
  var Project = external_exports.object({
23248
23258
  id: Id,
@@ -23250,8 +23260,6 @@ var Project = external_exports.object({
23250
23260
  key: external_exports.string(),
23251
23261
  name: external_exports.string(),
23252
23262
  description: external_exports.string(),
23253
- /** Null: no theme of its own; the viewer's personal theme applies. */
23254
- theme: ProjectTheme.nullable(),
23255
23263
  /** The board's columns, in order. */
23256
23264
  statuses: external_exports.array(ProjectStatus),
23257
23265
  createdAt: Timestamp,
@@ -23266,8 +23274,7 @@ var CreateProjectInput = external_exports.object({
23266
23274
  });
23267
23275
  var UpdateProjectInput = external_exports.object({
23268
23276
  name: external_exports.string().trim().min(1).max(80),
23269
- description: external_exports.string().trim().max(2e3),
23270
- theme: ProjectTheme.nullable()
23277
+ description: external_exports.string().trim().max(2e3)
23271
23278
  }).partial();
23272
23279
  function suggestProjectKey(name) {
23273
23280
  const words = name.toUpperCase().replace(/[^A-Z0-9 ]+/g, " ").split(/\s+/).filter(Boolean);
@@ -23298,6 +23305,44 @@ var UpdateItemInput = external_exports.object({ title, note, done: external_expo
23298
23305
  var ReorderItemsInput = external_exports.object({ ids: external_exports.array(Id).min(1) });
23299
23306
  var ItemProgress = external_exports.object({ done: external_exports.number(), total: external_exports.number() });
23300
23307
 
23308
+ // ../contract/src/work/threads.ts
23309
+ var MessageVia = external_exports.enum(["person", "agent"]);
23310
+ var ThreadMessage = external_exports.object({
23311
+ id: Id,
23312
+ threadId: Id,
23313
+ author: UserRef,
23314
+ /** A person on the web, or an agent through the CLI (with its machine). */
23315
+ via: MessageVia,
23316
+ machine: external_exports.string().nullable(),
23317
+ body: external_exports.string(),
23318
+ createdAt: Timestamp
23319
+ });
23320
+ var Thread = external_exports.object({
23321
+ id: Id,
23322
+ projectId: Id,
23323
+ /** Null: general. */
23324
+ taskId: Id.nullable(),
23325
+ taskKey: external_exports.string().nullable(),
23326
+ subject: external_exports.string(),
23327
+ createdBy: UserRef,
23328
+ createdAt: Timestamp,
23329
+ lastMessageAt: Timestamp,
23330
+ messageCount: external_exports.number(),
23331
+ /** Messages the viewer has not read. Their own count as read. */
23332
+ unread: external_exports.number(),
23333
+ last: external_exports.object({ author: UserRef, via: MessageVia, body: external_exports.string() }).nullable()
23334
+ });
23335
+ var ThreadDetail = Thread.extend({ messages: external_exports.array(ThreadMessage) });
23336
+ var body = external_exports.string().trim().min(1, "Say something").max(8e3);
23337
+ var CreateThreadInput = external_exports.object({
23338
+ subject: external_exports.string().trim().min(1, "Give it a subject").max(200),
23339
+ /** A task key or number; absent means general. */
23340
+ task: external_exports.string().trim().min(1).optional(),
23341
+ body
23342
+ });
23343
+ var ReplyInput = external_exports.object({ body });
23344
+ var ProjectThreadSummary = external_exports.object({ projectId: Id, unread: external_exports.number(), threads: external_exports.array(Thread) });
23345
+
23301
23346
  // ../contract/src/work/tasks.ts
23302
23347
  var TaskStatus = StatusKey;
23303
23348
  var TASK_SEVERITIES = ["low", "medium", "high", "critical"];
@@ -23408,8 +23453,12 @@ var AskInput = external_exports.object({ body: external_exports.string().trim().
23408
23453
  var SessionPush = external_exports.discriminatedUnion("type", [
23409
23454
  external_exports.object({ type: external_exports.literal("hello"), session: Session }),
23410
23455
  external_exports.object({ type: external_exports.literal("event"), record: TaskEventRecord }),
23456
+ /** A message in a thread about the task this session is on. */
23457
+ external_exports.object({ type: external_exports.literal("thread"), thread: external_exports.object({ id: Id, subject: external_exports.string(), taskKey: external_exports.string().nullable() }), message: ThreadMessage }),
23411
23458
  /** The room is closing this session (someone ran `slop session end`). */
23412
- external_exports.object({ type: external_exports.literal("end") })
23459
+ external_exports.object({ type: external_exports.literal("end") }),
23460
+ /** To board watchers: something in the project changed; refetch. */
23461
+ external_exports.object({ type: external_exports.literal("changed"), scope: external_exports.string() })
23413
23462
  ]);
23414
23463
 
23415
23464
  // ../contract/src/work/bindings.ts
@@ -23483,16 +23532,16 @@ var CliError = class extends Error {
23483
23532
 
23484
23533
  // src/platform/api.ts
23485
23534
  function createApi(options) {
23486
- async function request(method, path3, body) {
23535
+ async function request(method, path3, body2) {
23487
23536
  const headers = { accept: "application/json" };
23488
- if (body !== void 0) headers["content-type"] = "application/json";
23537
+ if (body2 !== void 0) headers["content-type"] = "application/json";
23489
23538
  if (options.token) headers.authorization = `Bearer ${options.token}`;
23490
23539
  let response;
23491
23540
  try {
23492
23541
  response = await fetch(`${options.baseUrl}/api${path3}`, {
23493
23542
  method,
23494
23543
  headers,
23495
- body: body === void 0 ? void 0 : JSON.stringify(body),
23544
+ body: body2 === void 0 ? void 0 : JSON.stringify(body2),
23496
23545
  signal: AbortSignal.timeout(2e4)
23497
23546
  });
23498
23547
  } catch (error62) {
@@ -23515,9 +23564,9 @@ function createApi(options) {
23515
23564
  return {
23516
23565
  baseUrl: options.baseUrl,
23517
23566
  get: (path3) => request("GET", path3),
23518
- post: (path3, body) => request("POST", path3, body ?? {}),
23519
- patch: (path3, body) => request("PATCH", path3, body),
23520
- put: (path3, body) => request("PUT", path3, body),
23567
+ post: (path3, body2) => request("POST", path3, body2 ?? {}),
23568
+ patch: (path3, body2) => request("PATCH", path3, body2),
23569
+ put: (path3, body2) => request("PUT", path3, body2),
23521
23570
  delete: (path3) => request("DELETE", path3)
23522
23571
  };
23523
23572
  }
@@ -23640,23 +23689,25 @@ function openBrowser(url2) {
23640
23689
  }
23641
23690
  }
23642
23691
  var sleep = (ms) => new Promise((done) => setTimeout(done, ms));
23692
+ var headless = () => Boolean(process.env.SSH_CONNECTION || process.env.SSH_TTY) || platform2() === "linux" && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY;
23643
23693
  function registerAuthCommands(program3) {
23644
- program3.command("login").description("Sign this machine in. Opens the web app, where you approve the login.").option("--url <url>", "the Slopware Tasks instance to sign in to").option("--no-browser", "print the link instead of opening it").action(async (options) => {
23694
+ program3.command("login").description("Sign this machine in. Opens the web app, where you approve the login. On a VM or over SSH, open the printed link on any device.").option("--url <url>", "the Slopware Tasks instance to sign in to").option("--no-browser", "print the link instead of opening it").action(async (options) => {
23645
23695
  const baseUrl = (options.url || process.env.SLOP_URL || readConfig()?.baseUrl || DEFAULT_URL).replace(/\/+$/, "");
23646
23696
  const api = createApi({ baseUrl, token: null });
23647
- const started = await api.post("/cli/auth/start", { machine: machineInfo() });
23697
+ const started2 = await api.post("/cli/auth/start", { machine: machineInfo() });
23698
+ const remote = headless();
23648
23699
  process.stderr.write(`
23649
- Approve this login in your browser:
23650
- ${started.verifyUrl}
23700
+ Approve this login in a browser${remote ? " on any device (this machine has no browser; your laptop or phone is fine)" : ""}:
23701
+ ${started2.verifyUrl}
23651
23702
 
23652
- Confirm the code matches: ${started.userCode}
23703
+ Confirm the code matches: ${started2.userCode}
23653
23704
 
23654
23705
  `);
23655
- if (options.browser) openBrowser(started.verifyUrl);
23656
- const deadline = Date.now() + started.expiresIn * 1e3;
23706
+ if (options.browser && !remote) openBrowser(started2.verifyUrl);
23707
+ const deadline = Date.now() + started2.expiresIn * 1e3;
23657
23708
  while (Date.now() < deadline) {
23658
- await sleep(started.interval * 1e3);
23659
- const poll = await api.post("/cli/auth/poll", { deviceCode: started.deviceCode });
23709
+ await sleep(started2.interval * 1e3);
23710
+ const poll = await api.post("/cli/auth/poll", { deviceCode: started2.deviceCode });
23660
23711
  if (poll.status === "pending") continue;
23661
23712
  if (poll.status === "denied") throw new CliError("The login was denied in the web app");
23662
23713
  if (poll.status === "expired") break;
@@ -23695,7 +23746,7 @@ This repository is linked to a Slopware Tasks board. Use the \`slop\` CLI to kee
23695
23746
  - Before starting tracked work: \`slop task list\`, then \`slop task start <KEY>\`.
23696
23747
  - When it is finished and verified: \`slop task done <KEY>\`.
23697
23748
  - 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 -\`.
23698
- - Claim a task with \`slop session start <KEY>\` and run \`slop session wait <KEY>\` in the background so other agents can reach you (\`slop task ask\`); \`slop sessions\` shows who is live. Method: \`slop skill slop-sessions\`.
23749
+ - Claim a task with \`slop session start <KEY>\` (it prints what was said to you) and run \`slop session wait <KEY>\` in the background so other agents can reach you. 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\`.
23699
23750
  - 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.
23700
23751
  - 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.
23701
23752
  - Add \`--json\` to any command for machine-readable output. \`slop task --help\` lists everything.
@@ -23721,8 +23772,9 @@ function git(args, cwd = process.cwd()) {
23721
23772
  function findRepo() {
23722
23773
  const commonDir = git(["rev-parse", "--path-format=absolute", "--git-common-dir"]);
23723
23774
  if (!commonDir) return null;
23724
- const root = basename(commonDir) === ".git" ? resolve(commonDir, "..") : git(["rev-parse", "--show-toplevel"]) ?? commonDir;
23725
- return { root, linkPath: join4(commonDir, "slop.json") };
23775
+ const worktree = git(["rev-parse", "--show-toplevel"]);
23776
+ const root = basename(commonDir) === ".git" ? resolve(commonDir, "..") : worktree ?? commonDir;
23777
+ return { root, worktree: worktree ?? root, linkPath: join4(commonDir, "slop.json") };
23726
23778
  }
23727
23779
  function requireRepo() {
23728
23780
  const repo = findRepo();
@@ -23845,17 +23897,13 @@ function registerProjectCommands(program3) {
23845
23897
  const statuses = await api.put(`/projects/${target.id}/statuses/order`, { ids: refs.map((ref) => columnOf(target, ref).id) });
23846
23898
  emit(statuses, () => list(statuses));
23847
23899
  });
23848
- project.command("update").description("Change the linked project's name, description or theme.").option("-p, --project <key>", "project key (default: the linked project)").option("-n, --name <name>").option("-d, --description <text>").option("--theme <theme>", `${PROJECT_THEMES.join(" | ")} | none`).action(async (options) => {
23900
+ project.command("update").description("Change the linked project's name or description.").option("-p, --project <key>", "project key (default: the linked project)").option("-n, --name <name>").option("-d, --description <text>").action(async (options) => {
23849
23901
  const api = authedApi();
23850
23902
  const target = await resolveProject(api, options.project);
23851
- const theme = options.theme === void 0 ? void 0 : options.theme === "none" ? null : options.theme;
23852
- if (theme && !PROJECT_THEMES.includes(theme)) {
23853
- throw new CliError(`Unknown theme "${theme}"`, `use one of: ${PROJECT_THEMES.join(", ")}, none`);
23903
+ if (options.name === void 0 && options.description === void 0) {
23904
+ throw new CliError("Nothing to change", "pass --name or --description");
23854
23905
  }
23855
- if (options.name === void 0 && options.description === void 0 && theme === void 0) {
23856
- throw new CliError("Nothing to change", "pass --name, --description or --theme");
23857
- }
23858
- const updated = await api.patch(`/projects/${target.id}`, { name: options.name, description: options.description, theme });
23906
+ const updated = await api.patch(`/projects/${target.id}`, { name: options.name, description: options.description });
23859
23907
  emit(updated, () => `Updated ${updated.key} ${updated.name}`);
23860
23908
  });
23861
23909
  project.command("create <name>").description("Create a project.").option("-k, --key <key>", "task key prefix, e.g. WEB (default: derived from the name)").option("-d, --description <text>", "what the project is").option("--org <slug-or-id>", "which org, if you belong to several").action(async (name, options) => {
@@ -23891,24 +23939,102 @@ Try: slop task create "My first task"`);
23891
23939
  program3.command("status").description("Show what this repository is linked to.").action(async () => {
23892
23940
  const repo = findRepo();
23893
23941
  const link = repo ? readLink(repo) : null;
23942
+ const where = !repo ? "" : repo.worktree === repo.root ? repo.root : `${repo.worktree} (worktree of ${repo.root})`;
23894
23943
  emit(
23895
- { repo: repo?.root ?? null, link },
23896
- () => !repo ? "Not inside a git repository." : !link ? `${repo.root}
23897
- not linked. Run: slop link <PROJECT_KEY>` : `${repo.root}
23944
+ { repo: repo?.root ?? null, worktree: repo?.worktree ?? null, link },
23945
+ () => !repo ? "Not inside a git repository." : !link ? `${where}
23946
+ not linked. Run: slop link <PROJECT_KEY>` : `${where}
23898
23947
  linked to ${link.projectKey} ${link.projectName} (${link.baseUrl})`
23899
23948
  );
23900
23949
  });
23901
23950
  }
23902
23951
 
23903
23952
  // src/commands/sessions.ts
23904
- import { readFileSync as readFileSync7 } from "fs";
23953
+ import { readFileSync as readFileSync8 } from "fs";
23954
+
23955
+ // src/commands/threads.ts
23956
+ import { readFileSync as readFileSync5 } from "fs";
23957
+ var readText = (value) => value === "-" ? readFileSync5(0, "utf8").trim() : value;
23958
+ var when = (iso) => iso.slice(0, 16).replace("T", " ");
23959
+ var who = (message) => `${message.author.name}${message.via === "agent" ? ` (agent${message.machine ? ` \xB7 ${message.machine}` : ""})` : ""}`;
23960
+ function threadLine(thread) {
23961
+ const scope = thread.taskKey ?? "general";
23962
+ const unread = thread.unread > 0 ? ` ${thread.unread} unread` : "";
23963
+ const last = thread.last ? `
23964
+ ${who(thread.last)}: ${thread.last.body.split("\n")[0]}` : "";
23965
+ return `${thread.id} [${scope}] ${thread.subject} (${thread.messageCount} msg, ${when(thread.lastMessageAt)})${unread}${last}`;
23966
+ }
23967
+ function messageBlock(message) {
23968
+ return `${who(message)} ${when(message.createdAt)}
23969
+ ${message.body.replace(/^/gm, " ")}`;
23970
+ }
23971
+ function threadBlock(thread) {
23972
+ return [`${thread.subject} [${thread.taskKey ?? "general"}] ${thread.id}`, "", ...thread.messages.map(messageBlock)].join("\n\n").replace("\n\n\n", "\n\n");
23973
+ }
23974
+ function pushBlock(push) {
23975
+ return `Thread ${push.thread.id} [${push.thread.taskKey ?? "general"}] ${push.thread.subject}
23976
+ ${messageBlock(push.message)}
23977
+ Reply: slop thread reply ${push.thread.id} "\u2026"`;
23978
+ }
23979
+ function registerThreadCommands(program3, task) {
23980
+ const withProject = (command) => command.option("-p, --project <key>", "project key (default: the project this repository is linked to)");
23981
+ withProject(program3.command("threads")).description("List the project's threads, newest activity first.").option("-t, --task <key>", "only threads about this task").option("--general", "only general threads").option("--unread", "only threads with messages you have not read").action(async (options) => {
23982
+ const api = authedApi();
23983
+ const project = await resolveProject(api, options.project);
23984
+ const query = new URLSearchParams();
23985
+ if (options.task) query.set("task", options.task);
23986
+ if (options.general) query.set("scope", "general");
23987
+ if (options.unread) query.set("unread", "1");
23988
+ const threads = await api.get(`/projects/${project.id}/threads${query.size ? `?${query}` : ""}`);
23989
+ emit(threads, () => threads.length === 0 ? "No threads." : threads.map(threadLine).join("\n"));
23990
+ });
23991
+ const thread = program3.command("thread").description("One thread: start, read, reply.");
23992
+ withProject(thread.command("new <subject>")).description('Start a thread. General by default; --task makes it about a task (and reaches whoever is live on it). The first message is -m, or "-" for stdin.').requiredOption("-m, --message <text>", 'the first message; "-" reads stdin').option("-t, --task <key>", "about this task").action(async (subject, options) => {
23993
+ const api = authedApi();
23994
+ const project = await resolveProject(api, options.project);
23995
+ const result = await api.post(`/projects/${project.id}/threads`, { subject, task: options.task, body: readText(options.message) });
23996
+ emit(result, () => started(result));
23997
+ });
23998
+ withProject(thread.command("show <id>")).description("Read a thread (and mark it read).").action(async (id, options) => {
23999
+ const api = authedApi();
24000
+ const project = await resolveProject(api, options.project);
24001
+ const detail = await api.get(`/projects/${project.id}/threads/${id}`);
24002
+ emit(detail, () => threadBlock(detail));
24003
+ });
24004
+ withProject(thread.command("reply <id> <text>")).description('Reply in a thread. About a task: reaches whoever is live on it. "-" reads stdin.').action(async (id, text, options) => {
24005
+ const api = authedApi();
24006
+ const project = await resolveProject(api, options.project);
24007
+ const result = await api.post(`/projects/${project.id}/threads/${id}/messages`, { body: readText(text) });
24008
+ emit(result, () => result.delivered ? `Replied; reached ${result.delivered} live session${result.delivered === 1 ? "" : "s"}.` : "Replied. Nobody is live on it; they see it when they start.");
24009
+ });
24010
+ withProject(program3.command("post <text>")).description('Start a general thread from one message (the first line is the subject). For a task, use --task. "-" reads stdin.').option("-t, --task <key>", "about this task").action(async (text, options) => {
24011
+ const api = authedApi();
24012
+ const project = await resolveProject(api, options.project);
24013
+ const body2 = readText(text);
24014
+ const result = await api.post(`/projects/${project.id}/threads`, { subject: subjectOf(body2), task: options.task, body: body2 });
24015
+ emit(result, () => started(result));
24016
+ });
24017
+ withProject(task.command("ask <key> <text>")).description('Start a thread about the task and say something to whoever is live on it. "-" reads stdin.').action(async (key, text, options) => {
24018
+ const api = authedApi();
24019
+ const project = await resolveProject(api, options.project);
24020
+ const body2 = readText(text);
24021
+ const result = await api.post(`/projects/${project.id}/threads`, { subject: subjectOf(body2), task: key, body: body2 });
24022
+ emit(result, () => started(result));
24023
+ });
24024
+ }
24025
+ var subjectOf = (body2) => {
24026
+ const line = body2.split("\n")[0]?.trim() ?? "";
24027
+ if (!line) throw new CliError("Say something", "the first line becomes the subject");
24028
+ return line.length > 120 ? `${line.slice(0, 117)}\u2026` : line;
24029
+ };
24030
+ var started = (result) => result.thread.taskKey ? result.delivered ? `Started ${result.thread.id} on ${result.thread.taskKey}; reached ${result.delivered} live session${result.delivered === 1 ? "" : "s"}.` : `Started ${result.thread.id} on ${result.thread.taskKey}. Nobody is live on it; they see it when they start.` : `Started ${result.thread.id} (general). People see it on the web; agents at their next session start.`;
23905
24031
 
23906
24032
  // src/commands/tasks.ts
23907
- import { readFileSync as readFileSync6 } from "fs";
24033
+ import { readFileSync as readFileSync7 } from "fs";
23908
24034
 
23909
24035
  // src/commands/items.ts
23910
- import { readFileSync as readFileSync5 } from "fs";
23911
- var readText = (value) => value === "-" ? readFileSync5(0, "utf8").trim() : value;
24036
+ import { readFileSync as readFileSync6 } from "fs";
24037
+ var readText2 = (value) => value === "-" ? readFileSync6(0, "utf8").trim() : value;
23912
24038
  var path2 = (projectId, key) => `/projects/${projectId}/tasks/${encodeURIComponent(key)}/items`;
23913
24039
  function renderItems(items) {
23914
24040
  if (items.length === 0) return "No plan yet. Write one: slop task plan <KEY> -";
@@ -23935,7 +24061,7 @@ function registerItemCommands(task, withProject) {
23935
24061
  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) => {
23936
24062
  const api = authedApi();
23937
24063
  const project = await resolveProject(api, options.project);
23938
- const source = lines.length === 1 && lines[0] === "-" ? readFileSync5(0, "utf8") : lines.join("\n");
24064
+ const source = lines.length === 1 && lines[0] === "-" ? readFileSync6(0, "utf8") : lines.join("\n");
23939
24065
  const titles = source.split("\n").map((line) => line.replace(/^\s*(?:[-*+]|\d+[.)])\s*(?:\[[ xX]\]\s*)?/, "").trim()).filter(Boolean);
23940
24066
  if (titles.length === 0) throw new CliError("Nothing to add", 'give items as arguments, or "-" and one per line on stdin');
23941
24067
  const items = await api.post(path2(project.id, key), { items: titles.map((title3) => ({ title: title3 })) });
@@ -23945,7 +24071,7 @@ function registerItemCommands(task, withProject) {
23945
24071
  withProject(item.command("add <key> <title>")).description("Add an item to the end of the plan.").option("-n, --note <text>", 'what is true about it now; "-" reads stdin').action(async (key, title3, options) => {
23946
24072
  const api = authedApi();
23947
24073
  const project = await resolveProject(api, options.project);
23948
- const items = await api.post(path2(project.id, key), { title: title3, note: options.note ? readText(options.note) : "" });
24074
+ const items = await api.post(path2(project.id, key), { title: title3, note: options.note ? readText2(options.note) : "" });
23949
24075
  emit(items, () => renderItems(items));
23950
24076
  });
23951
24077
  for (const [name, done, help] of [
@@ -23964,7 +24090,7 @@ function registerItemCommands(task, withProject) {
23964
24090
  const api = authedApi();
23965
24091
  const project = await resolveProject(api, options.project);
23966
24092
  const target = await itemAt(api, project.id, key, ref);
23967
- const items = await api.patch(`${path2(project.id, key)}/${target.id}`, { note: readText(text) });
24093
+ const items = await api.patch(`${path2(project.id, key)}/${target.id}`, { note: readText2(text) });
23968
24094
  emit(items, () => renderItems(items));
23969
24095
  });
23970
24096
  withProject(item.command("rename <key> <item> <title>")).description("Retitle an item.").action(async (key, ref, title3, options) => {
@@ -23992,7 +24118,7 @@ function oneOf(value, allowed, what) {
23992
24118
  if (allowed.includes(normalized)) return normalized;
23993
24119
  throw new CliError(`Unknown ${what} "${value}"`, `use one of: ${allowed.join(", ")}`);
23994
24120
  }
23995
- var readDescription = (value) => value === "-" ? readFileSync6(0, "utf8").trim() : value;
24121
+ var readDescription = (value) => value === "-" ? readFileSync7(0, "utf8").trim() : value;
23996
24122
  var taskUrl = (baseUrl, projectId, task) => `${baseUrl}/projects/${projectId}?task=${task.key}`;
23997
24123
  var loadProject = (api, id) => api.get(`/projects/${id}`);
23998
24124
  function resolveStatus(project, ref) {
@@ -24007,17 +24133,17 @@ function statusInCategory(project, category) {
24007
24133
  return status.key;
24008
24134
  }
24009
24135
  var ASSIGNEE_HELP = 'an email, a name, "me", or "none"';
24010
- async function resolveAssignee(api, projectId, who) {
24011
- if (who === void 0) return void 0;
24012
- if (who.toLowerCase() === "none") return null;
24013
- if (who.toLowerCase() === "me") return (await api.get("/me")).user.id;
24014
- const needle = who.toLowerCase();
24136
+ async function resolveAssignee(api, projectId, who2) {
24137
+ if (who2 === void 0) return void 0;
24138
+ if (who2.toLowerCase() === "none") return null;
24139
+ if (who2.toLowerCase() === "me") return (await api.get("/me")).user.id;
24140
+ const needle = who2.toLowerCase();
24015
24141
  const members2 = await listMembers(api, projectId);
24016
24142
  const exact = members2.filter((member) => member.email.toLowerCase() === needle || member.name.toLowerCase() === needle);
24017
24143
  const matches = exact.length > 0 ? exact : members2.filter((member) => member.email.toLowerCase().includes(needle) || member.name.toLowerCase().includes(needle));
24018
24144
  if (matches.length === 1) return matches[0].userId;
24019
- if (matches.length === 0) throw new CliError(`Nobody in this project matches "${who}"`, "slop members");
24020
- throw new CliError(`"${who}" matches ${matches.map((member) => member.email).join(", ")}`, "use the full email");
24145
+ if (matches.length === 0) throw new CliError(`Nobody in this project matches "${who2}"`, "slop members");
24146
+ throw new CliError(`"${who2}" matches ${matches.map((member) => member.email).join(", ")}`, "use the full email");
24021
24147
  }
24022
24148
  function registerTaskCommands(program3) {
24023
24149
  const task = program3.command("task").alias("tasks").description("Create, read and move tasks in the linked project.");
@@ -24090,10 +24216,10 @@ ${taskUrl(api.baseUrl, project.id, created)}`);
24090
24216
  const updated = await api.patch(`/projects/${project.id}/tasks/${encodeURIComponent(key)}`, { status: resolveStatus(project, column) });
24091
24217
  emit(updated, () => `${updated.key} is now in ${statusLabel(project.statuses, updated.status)}.`);
24092
24218
  });
24093
- withProject(task.command("assign <key> <who>")).description(`Assign a task to ${ASSIGNEE_HELP}.`).action(async (key, who, options) => {
24219
+ withProject(task.command("assign <key> <who>")).description(`Assign a task to ${ASSIGNEE_HELP}.`).action(async (key, who2, options) => {
24094
24220
  const api = authedApi();
24095
24221
  const project = await resolveProject(api, options.project);
24096
- const updated = await api.patch(`/projects/${project.id}/tasks/${encodeURIComponent(key)}`, { assigneeId: await resolveAssignee(api, project.id, who) });
24222
+ const updated = await api.patch(`/projects/${project.id}/tasks/${encodeURIComponent(key)}`, { assigneeId: await resolveAssignee(api, project.id, who2) });
24097
24223
  emit(updated, () => `${updated.key} is assigned to ${updated.assignee?.name ?? "nobody"}.`);
24098
24224
  });
24099
24225
  for (const [name, category] of [["start", "in_progress"], ["done", "done"]]) {
@@ -24144,11 +24270,10 @@ ${taskUrl(api.baseUrl, project.id, created)}`);
24144
24270
 
24145
24271
  // src/commands/sessions.ts
24146
24272
  var taskPath = (projectId, key) => `/projects/${projectId}/tasks/${encodeURIComponent(key)}`;
24147
- var readText2 = (value) => value === "-" ? readFileSync7(0, "utf8").trim() : value;
24148
24273
  var sessionLine = (session) => [session.taskKey, session.user.name, session.machine.name, session.startedAt];
24149
24274
  function messageLine(record2) {
24150
- const body = record2.event.kind === "message" ? record2.event.body : JSON.stringify(record2.event);
24151
- return `${record2.actor.name} (${record2.createdAt}): ${body}`;
24275
+ const body2 = record2.event.kind === "message" ? record2.event.body : JSON.stringify(record2.event);
24276
+ return `${record2.actor.name} (${record2.createdAt}): ${body2}`;
24152
24277
  }
24153
24278
  function registerSessionCommands(program3, task) {
24154
24279
  const withProject = (command) => command.option("-p, --project <key>", "project key (default: the project this repository is linked to)");
@@ -24158,10 +24283,19 @@ function registerSessionCommands(program3, task) {
24158
24283
  const project = await loadProject(api, (await resolveProject(api, options.project)).id);
24159
24284
  const assigneeId = await resolveAssignee(api, project.id, "me");
24160
24285
  const updated = await api.patch(taskPath(project.id, key), { assigneeId, status: statusInCategory(project, "in_progress") });
24161
- emit(updated, () => `Claimed ${updated.key} ${updated.title}
24162
- Go live (in the background, and relaunch it after each message): slop session wait ${updated.key}`);
24286
+ const [about, general] = await Promise.all([
24287
+ api.get(`/projects/${project.id}/threads?task=${encodeURIComponent(updated.key)}&unread=1&read=1`),
24288
+ api.get(`/projects/${project.id}/threads?scope=general&unread=1&read=1`)
24289
+ ]);
24290
+ emit({ task: updated, threads: { task: about, general } }, () => {
24291
+ const lines = [`Claimed ${updated.key} ${updated.title}`];
24292
+ if (about.length) lines.push("", `Unread about ${updated.key} (read one with slop thread show <id>):`, ...about.map(threadLine));
24293
+ if (general.length) lines.push("", `Unread general threads (for context; nobody has to answer):`, ...general.map(threadLine));
24294
+ lines.push("", `Go live (in the background, and relaunch it after each message): slop session wait ${updated.key}`);
24295
+ return lines.join("\n");
24296
+ });
24163
24297
  });
24164
- withProject(session.command("wait <key>")).description("Hold a live session on the task until someone says something; print it and exit. Exit 2 if the session was ended, 3 on timeout, 4 if the connection dropped (relaunch).").option("--timeout <seconds>", "give up after this long (default: never)").action(async (key, options) => {
24298
+ withProject(session.command("wait <key>")).description("Hold a live session on the task until someone says something in a thread about it; print it and exit. Exit 2 if the session was ended, 3 on timeout, 4 if the connection dropped (relaunch). --follow stays up and prints each message as it comes.").option("--timeout <seconds>", "give up after this long (default: never)").option("--follow", "keep the session and print every message instead of exiting on the first").action(async (key, options) => {
24165
24299
  const api = authedApi();
24166
24300
  const project = await resolveProject(api, options.project);
24167
24301
  const ticket = await api.post(`${taskPath(project.id, key)}/session/ticket`);
@@ -24183,6 +24317,16 @@ Go live (in the background, and relaunch it after each message): slop session w
24183
24317
  resolve2({ ended: true });
24184
24318
  return;
24185
24319
  }
24320
+ if (push.type === "thread") {
24321
+ if (options.follow) {
24322
+ emit({ thread: push.thread, message: push.message }, () => `${pushBlock(push)}
24323
+ `);
24324
+ return;
24325
+ }
24326
+ resolve2({ push });
24327
+ return;
24328
+ }
24329
+ if (push.type !== "event") return;
24186
24330
  resolve2({ record: push.record });
24187
24331
  });
24188
24332
  socket.addEventListener("close", (event) => {
@@ -24195,8 +24339,9 @@ Go live (in the background, and relaunch it after each message): slop session w
24195
24339
  resolve2({ closed: "socket error" });
24196
24340
  });
24197
24341
  });
24198
- if ("record" in outcome) {
24199
- emit(outcome.record, () => messageLine(outcome.record));
24342
+ if ("record" in outcome || "push" in outcome) {
24343
+ if ("push" in outcome) emit(outcome.push, () => pushBlock(outcome.push));
24344
+ else emit(outcome.record, () => messageLine(outcome.record));
24200
24345
  socket.close(1e3, "handled");
24201
24346
  await new Promise((resolve2) => {
24202
24347
  socket.addEventListener("close", () => resolve2(), { once: true });
@@ -24223,12 +24368,6 @@ Go live (in the background, and relaunch it after each message): slop session w
24223
24368
  const sessions = await api.get(`/projects/${project.id}/sessions`);
24224
24369
  emit(sessions, () => sessions.length === 0 ? "Nobody is live." : table([["TASK", "WHO", "MACHINE", "SINCE"], ...sessions.map(sessionLine)]));
24225
24370
  });
24226
- withProject(task.command("ask <key> <text>")).description('Say something to whoever is live on the task; "-" reads stdin. It also goes on the timeline.').action(async (key, text, options) => {
24227
- const api = authedApi();
24228
- const project = await resolveProject(api, options.project);
24229
- const result = await api.post(`${taskPath(project.id, key)}/ask`, { body: readText2(text) });
24230
- emit(result, () => result.delivered ? `Delivered to ${result.delivered} live session${result.delivered === 1 ? "" : "s"}.` : "Nobody is live on it; left on the timeline.");
24231
- });
24232
24371
  }
24233
24372
 
24234
24373
  // src/commands/skills.ts
@@ -24253,10 +24392,12 @@ ${written.map((path3) => ` ${path3}`).join("\n")}`);
24253
24392
  }
24254
24393
 
24255
24394
  // src/index.ts
24256
- 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.1.1").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)");
24395
+ 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.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)");
24257
24396
  registerAuthCommands(program2);
24258
24397
  registerProjectCommands(program2);
24259
- registerSessionCommands(program2, registerTaskCommands(program2));
24398
+ var taskCommand = registerTaskCommands(program2);
24399
+ registerSessionCommands(program2, taskCommand);
24400
+ registerThreadCommands(program2, taskCommand);
24260
24401
  registerGuideCommand(program2);
24261
24402
  registerSkillCommands(program2);
24262
24403
  program2.parseAsync().catch(fail);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "slopwaresystems",
3
- "version": "0.1.1",
3
+ "version": "0.2.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",