Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
b4a5c01
Add schema 13 with organization-scoped sessions
prineycom Nov 4, 2026
75bf4a0
Accept an organization slug as a session key
prineycom Nov 4, 2026
516e7ac
Give a task two parents and address events to the parent that wrote
prineycom Nov 4, 2026
38dcea6
Send turn events to the parent whose message started the turn
prineycom Nov 4, 2026
424f7d9
Expect schema 13 from the HTTP API
prineycom Nov 4, 2026
0024e76
Queue a milestone for the parent that last wrote to the task
prineycom Nov 4, 2026
5297d72
Nest project sessions under their organization in the sessions tree
prineycom Nov 4, 2026
97b1953
Open an organization session in its own Herdr workspace
prineycom Nov 4, 2026
1503984
Start an organization session with each project's open tasks
prineycom Nov 4, 2026
6011a11
Warm up an organization session with its projects and events
prineycom Nov 4, 2026
36142a6
Describe the organization chat in the role texts and howto
prineycom Nov 4, 2026
853f830
Report Pi turn events to the parent that sent the message and complet…
prineycom Nov 4, 2026
b82a5a2
Let pipeline skills run in an organization scope
prineycom Nov 4, 2026
9de9286
Document the organization session
prineycom Nov 4, 2026
d999d9d
Describe the remote milestone recipient by the last writing parent
prineycom Nov 4, 2026
143a8a1
State schema 13 in the HTTP API guide
prineycom Nov 4, 2026
f47890d
Describe the organization in the HTTP API parent rule
prineycom Nov 4, 2026
568cf39
Name the organization case in the board skill
prineycom Nov 4, 2026
0ed5e84
Check that the Pi extension copies the parent-message pattern
prineycom Nov 4, 2026
0ab8570
Name the organization slug among the open keys in the CLI usage
prineycom Nov 4, 2026
4a0cfcf
Let the remote send_message description name organizations
prineycom Nov 5, 2026
d6780e6
Condense the organization additions to the role texts and howto
prineycom Nov 5, 2026
b8dcd3f
Merge origin/main into task/MP-78-open-organization-scope
prineycom Nov 6, 2026
a7276cf
Name schema 14 for the organization sessions in the changelog and ADR…
prineycom Nov 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions .claude/skills/board/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
---
name: board
description: Show where a project's tasks stand — status, plan URL, PR, worktree, open derive threads — in one table, with the next sensible step per task; the project of the session scope, or the typed CODE in the main chat. Read-only. Triggered by "/board [CODE] [all]".
description: Show where a project's tasks stand — status, plan URL, PR, worktree, open derive threads — in one table, with the next sensible step per task; the project of the session scope, the projects of the organization in an organization chat, or the typed CODE in the main chat. Read-only. Triggered by "/board [CODE] [all]".
---

# /board — the board

`/board [CODE] [all]` — `CODE` is a project code, `all` also shows terminal tasks.
The word `all` is always the flag, never a `CODE`: `/board all` is the board of the
session's project (or MP in global) with terminal tasks.
session's project (or MP in global) with terminal tasks. In an organization chat
`/board` shows the boards of the organization's projects one after another, and
`/board all` the same with terminal tasks.

Read-only. `BIN=scripts/pipeline` (relative to the engine root, every session's working directory).

Expand All @@ -17,8 +19,11 @@ Read-only. `BIN=scripts/pipeline` (relative to the engine root, every session's
a typed `CODE` must be empty or equal `P`, otherwise print
`/board <CODE> is not allowed in session scope <key>; this chat shows the board of <P> only. Use /board here or /open global for another project.`
and stop;
- an organization (`org` is not `null`) — a typed `CODE` must be in `codes`
(else the organization refusal text of `docs/PIPELINE.md`); without a `CODE`
the boards of all `codes`, one after another;
- key `global` — the board of the typed `CODE`, `MP` by default;
- a malformed key (`project` is `null` and the key is not `global`) or a
- a malformed key (`project` and `org` are `null` and the key is not `global`) or a
non-zero exit of `scope` — print
`The board cannot tell this session's project (scope <key>). Open the main chat with /open global.`
and stop;
Expand Down
3 changes: 2 additions & 1 deletion .claude/skills/do/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ teammate.
## Step 0 — session scope

`$BIN/mp-task scope`, then apply the «Session scope» rule of `docs/PIPELINE.md` to
`/do <KEY>`: `global` — any key; `P` — continue only when the key has the code `P`;
`/do <KEY>`: `global` — any key; an organization (`org` is not `null`) — continue
only when the key's code is in `codes`; `P` — continue only when the key has the code `P`;
`P-N` — continue only when the key is `P-N` or its subtask by
`$BIN/mp-task subtasks P-N`; anything else (an argument that is not a key, a
malformed session key, a non-zero exit) — print the refusal text from
Expand Down
9 changes: 5 additions & 4 deletions .claude/skills/flow/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,11 @@ reads.

`$BIN/mp-task scope`, then apply the «Session scope» rule of `docs/PIPELINE.md` to
`/flow`: `global` — continue when every key has the same code (keys of several
projects get the «mixes projects» text); `P` — continue when every key has the code
`P`; `P-N` — continue when every key is `P-N` or its subtask by
`$BIN/mp-task subtasks P-N`; an argument that is not a key (other than the run
name), a malformed session key or a non-zero exit — refused. A refusal prints its text from
projects get the «mixes projects» text); an organization (`org` is not `null`) —
continue when every key has the same code and that code is in `codes`; `P` —
continue when every key has the code `P`; `P-N` — continue when every key is `P-N`
or its subtask by `$BIN/mp-task subtasks P-N`; an argument that is not a key (other
than the run name), a malformed session key or a non-zero exit — refused. A refusal prints its text from
`docs/PIPELINE.md` and stops. This step runs before «Preconditions», before
`git fetch`, before the flow branch is created or pushed and before the flow score
is written. The nested plan and do runs go in this same session and pass their
Expand Down
5 changes: 3 additions & 2 deletions .claude/skills/plan/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,9 @@ order typed.
## Step 0 — session scope

`$BIN/mp-task scope`, then apply the «Session scope» rule of `docs/PIPELINE.md` to
`/plan` and every typed key: `global` — any key; `P` — continue only when every
typed key has the code `P`; `P-N` — continue only when every typed key is `P-N`
`/plan` and every typed key: `global` — any key; an organization (`org` is not
`null`) — continue only when every typed key has a code in `codes`; `P` — continue
only when every typed key has the code `P`; `P-N` — continue only when every typed key is `P-N`
or its subtask by `$BIN/mp-task subtasks P-N`; anything else (an argument that is not a key, a malformed session key, a
non-zero exit) — print the refusal text from `docs/PIPELINE.md` and stop. This step
runs before the status check in «Input», before `$BIN/mp-task show` and before any
Expand Down
5 changes: 3 additions & 2 deletions .claude/skills/ship/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,9 @@ one entered. `BIN=scripts/pipeline` (relative to the engine root, every session'
## Step 0 — session scope

`$BIN/mp-task scope`, then apply the «Session scope» rule of `docs/PIPELINE.md` to
`/ship` and every typed argument: `global` — any keys and `flow/<name>`; `P` —
keys with the code `P` and a flow of project `P`; `P-N` — continue only when every
`/ship` and every typed argument: `global` — any keys and `flow/<name>`; an
organization (`org` is not `null`) — keys with a code in `codes` and a flow of one
project in `codes`; `P` — keys with the code `P` and a flow of project `P`; `P-N` — continue only when every
typed key is `P-N` or its subtask by `$BIN/mp-task subtasks P-N`, and for
`flow/<name>` when every key of the checkboxes of the flow score
`<home of P>/flows/<name>.md` is `P-N` or its subtask (no file — the refusal text
Expand Down
25 changes: 17 additions & 8 deletions .pi/extensions/mypi-lib/install.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { chmodSync, existsSync, mkdirSync, rmSync, statSync, unlinkSync } from 'node:fs';
import { join } from 'node:path';
import { completionSource, openCompletions, scopeCode } from './open-completions.js';
import { completionSource, openCompletions, scopeCodes } from './open-completions.js';
import type { CompletionItem, OpenCatalog } from './open-completions.js';

interface ExtensionContext {
Expand Down Expand Up @@ -41,7 +41,7 @@ export type ServeEvents = (path: string, onBatch: (events: { id: number; text: s
const SECTION = 'mypi_context';
export const CODEMODE_GUIDELINE = 'In codemode scripts `searchTools()`, `describeTool()` and `describeNamespace()` return Promises, like `tools.*` calls: `await` them before iterating or printing, e.g. `const hits = await searchTools("mcp__mypi open_session")`.';
const MESSAGE_LIMIT = 32000;
const PARENT_MESSAGE = /(?:^|\n)\[mypi: сообщение родительской сессии [A-Z]+ для [A-Z]+-[1-9][0-9]*\. /;
const PARENT_MESSAGE = /(?:^|\n)\[mypi: сообщение родительской сессии ([A-Z]+|[a-z0-9]+(?:[._-][a-z0-9]+)*) для [A-Z]+-[1-9][0-9]*\. /;

const unavailable: RunCli = async () => ({ code: null, signal: null, stdout: '', stderr: 'mypi open needs the CLI runner' });

Expand Down Expand Up @@ -93,12 +93,18 @@ export function install(pi: ExtensionAPI, root: string, serve: ServeEvents, runt
let started: (() => void) | undefined;
const injected = new Set<number>();
let parentTurn = false;
let parentFrom: string | undefined;
const onBatch = async (events: { id: number; text: string }[]): Promise<number[]> => {
const ids = events.map(e => e.id);
const fresh = events.filter(e => !injected.has(e.id));
if (fresh.length === 0) return ids;
const text = fresh.map(e => e.text).join('\n\n');
if (fresh.some(e => PARENT_MESSAGE.test(e.text))) parentTurn = true;
for (const e of fresh) {
for (const match of e.text.matchAll(new RegExp(PARENT_MESSAGE.source, 'g'))) {
parentTurn = true;
parentFrom = match[1];
}
}
if (starting !== undefined) {
await starting;
pi.sendUserMessage(text, { deliverAs: 'followUp' });
Expand Down Expand Up @@ -174,14 +180,17 @@ export function install(pi: ExtensionAPI, root: string, serve: ServeEvents, runt
.map(part => part.text).join('\n');
});
pi.on('agent_settled', async () => {
const report = parentTurn, reply = last;
const report = parentTurn, reply = last, to = parentFrom;
parentTurn = false;
parentFrom = undefined;
last = '';
if (key !== undefined && report) await pi.exec('node', [cli, 'event', 'emit', '--from', key, '--message=' + clip(reply)], { cwd: root, timeout: 5000 })
if (key !== undefined && report) await pi.exec('node', [cli, 'event', 'emit', '--from', key, ...(to === undefined ? [] : ['--to', to]),
'--message=' + clip(reply)], { cwd: root, timeout: 5000 })
.catch(() => undefined);
});
pi.on('ui_prompt_start', async event => {
if (key !== undefined && parentTurn) await pi.exec('node', [cli, 'event', 'emit', '--from', key, '--waiting', ...(event.title ? ['--message=' + event.title] : [])],
if (key !== undefined && parentTurn) await pi.exec('node', [cli, 'event', 'emit', '--from', key, ...(parentFrom === undefined ? [] : ['--to', parentFrom]),
'--waiting', ...(event.title ? ['--message=' + event.title] : [])],
{ cwd: root, timeout: 5000 }).catch(() => undefined);
});
pi.on('session_shutdown', async event => {
Expand All @@ -202,7 +211,7 @@ export function install(pi: ExtensionAPI, root: string, serve: ServeEvents, runt
description: 'Open or focus mypi sessions in Herdr tabs: /open <key...> [--agent claude|pi] [--permission-mode mode] [--message text]',
getArgumentCompletions: async prefix => {
const data = await catalog();
return data === undefined ? null : openCompletions(prefix, data, scopeCode(key));
return data === undefined ? null : openCompletions(prefix, data, scopeCodes(key, data.projects));
},
handler: async (args, ctx) => {
const marker = /(?:^|\s)--message(?:\s|$)/.exec(args);
Expand Down Expand Up @@ -245,7 +254,7 @@ export function install(pi: ExtensionAPI, root: string, serve: ServeEvents, runt
},
});
pi.registerCommand('inbox', {
description: 'Show incoming events from task chats: /inbox [CODE|all]',
description: 'Show incoming events from task chats: /inbox [org|CODE|all]',
handler: async args => {
pi.sendUserMessage(('/skill:inbox ' + args.trim()).trim(), { expandPromptTemplates: true });
},
Expand Down
16 changes: 10 additions & 6 deletions .pi/extensions/mypi-lib/open-completions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,26 @@ export interface OpenCatalog { projects: OpenProject[]; tasks: OpenTask[] }
export const COMPLETION_TTL = 15000;
export const COMPLETION_LIMIT = 50;

export function scopeCode(key: string | undefined): string | undefined {
export function scopeCodes(key: string | undefined, projects: OpenProject[]): string[] | undefined {
if (key === undefined || key === 'global') return undefined;
return /^([A-Z]+)(?:-[1-9][0-9]*)?$/.exec(key)?.[1];
const code = /^([A-Z]+)(?:-[1-9][0-9]*)?$/.exec(key)?.[1];
if (code !== undefined) return [code];
if (!/^[a-z0-9]+(?:[._-][a-z0-9]+)*$/.test(key)) return undefined;
return projects.filter(project => project.org === key).map(project => project.code).sort();
}

export function openCompletions(prefix: string, catalog: OpenCatalog, scope: string | undefined): CompletionItem[] | null {
export function openCompletions(prefix: string, catalog: OpenCatalog, scope: string[] | undefined): CompletionItem[] | null {
if (/(?:^|\s)--message(?:\s|$)/.test(prefix)) return null;
const token = /\S*$/.exec(prefix)![0];
const head = prefix.slice(0, prefix.length - token.length);
const words = head.trim().split(/\s+/).filter(Boolean);
if (token.startsWith('-') || ['--agent', '--permission-mode'].includes(words.at(-1) ?? '')) return null;
const own = scope === undefined ? undefined : catalog.projects.find(project => project.code === scope);
const projects = (own === undefined ? catalog.projects : [own]).slice().sort((a, b) => a.code < b.code ? -1 : a.code > b.code ? 1 : 0);
const matched = scope === undefined ? [] : catalog.projects.filter(project => scope.includes(project.code));
const own = matched.length === 0 ? undefined : new Set(matched.map(project => project.id));
const projects = (own === undefined ? catalog.projects : matched).slice().sort((a, b) => a.code < b.code ? -1 : a.code > b.code ? 1 : 0);
const codes = new Map(catalog.projects.map(project => [project.id, project.code]));
const tasks = catalog.tasks
.filter(task => task.projectId !== null && !task.isTerminal && (own === undefined || task.projectId === own.id))
.filter(task => task.projectId !== null && !task.isTerminal && (own === undefined || own.has(task.projectId!)))
.map(task => ({ task, code: codes.get(task.projectId!) ?? '' }))
.sort((a, b) => a.code < b.code ? -1 : a.code > b.code ? 1 : a.task.number - b.task.number);
const entries = [
Expand Down
49 changes: 49 additions & 0 deletions .yoke/adr/0005-organization-session.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# ADR 0005: Organization session

Date: 2026-11-05
Status: accepted

Supplements ADR 0001, ADR 0003 and ADR 0004.

## Context

A session had three scopes: `global`, a project (`MP`) and a task (`MP-12`). An
organization existed only as a memory level and in registrations, with no session
of its own. A plan that spans several projects of one organization had no single
chat: the work went through each project chat in turn.

## Decision

1. A fourth scope. The key of an organization session is the bare lowercase slug
of a registered organization (`tb-ff`). `global` and `all` stay reserved: `all`
is `/inbox all`. `/open tb-ff` opens, focuses or resumes it in the Herdr tab
`org-tb-ff` of the workspace `tb-ff`, in the engine root, with no clone and no
worktree. An organization without registered projects is refused.
2. Two parents of a task. A task's parent is its project chat or its organization
chat; either may send it a message. The parent of an organization chat, like
that of a project chat, is the main chat. The structure never changes: a task
belongs to one project, a project to one organization.
3. `turn_end` and `waiting` go to the sender of the parent message that started
the turn, taken from its frame `[mypi: сообщение родительской сессии <sender>
для <KEY>. …]`. A sender that is not a parent of the task (a forged or stale
frame) gives way to the task's project.
4. A milestone goes to the parent that last sent the task a message, else to the
task's project, exactly one per status change, and none when that recipient
makes the change itself. A last sender that is no longer a parent gives way to
the project.
5. Main chat to organization: the main chat writes to an organization chat as it
writes to a project chat; turns it starts create no events.
6. `events` is not migrated: rows keep their `from_key` and `to_key`, so nothing is
lost or duplicated when the hierarchy is read differently.
7. Migration 014 rebuilds `sessions` with the scope `org` and the column `org_id`
(an organization row has `org_id` and no project or task) and recreates the
trigger `used_project_code`.

## Consequences

- Schema 14: the installed engine needs `mypi db init` once after the update.
- `mypi sessions` shows the tree global → org → project → task; an organization
without its own row is a node with no session.
- `org remove` refuses while an organization session or an event with its slug
exists.
- Existing global, project and task sessions keep their keys and their routing.
Loading
Loading