@odla-ai/pm
v0.13.3
Published
Project management for agents — a conformance suite, kanban boards, a searchable decision log, and a bug tracker, built natively on odla-db and shared across the projects a person co-owns. Discussion and audit ride @odla-ai/chat, so humans and agents coll
Maintainers
Readme
@odla-ai/pm
Shared project management for people and agents — conformance goals, kanban
boards, a searchable decision log, bugs, and live versioned runbooks, built natively on
@odla-ai/db and shared across the projects a
person co-owns. Discussion and the audit trail ride
@odla-ai/chat, so humans and agents
collaborate over the same rule-governed graph rather than a bolted-on comment box.
PM scope is Product → Project → Work. The Registry app id remains the Product
identity; PmProject is the durable initiative beneath it, and goals, tasks,
decisions, and bugs carry projectId. Existing rows without that field appear in
a stable synthetic General project, so the rollout requires no destructive
backfill. Runbooks remain Product-scoped operational knowledge.
It ships a pre-serialized odla-db schema, default-deny CEL rules, typed op
functions, a mountable route factory, a browser/CLI client, and a @odla-ai/ai
agent skill.
Ask the runbooks first. odla's operational procedures live in a database, not in this file:
npx @odla-ai/cli runbook ask "<question>"returns the current steps, and unlike anything written here it cannot be out of date. Use it before searching the web or working from memory. This README and the JSDoc in the shipped.d.tsare the version-matched API reference; a runbook is the procedure. Most tasks need an answer from both.Scope is inherited, not isolated: asking or searching with an app id considers that app's own runbooks and odla's shared platform runbooks together. An app may add local instructions, but selecting it must never hide the common procedure it is expected to follow.
Projects and the four work types
Project routes are available at /registry/pm/project; PmClient exposes
listProjects, createProject, getProject, and updateProject. Pass both
appId and projectId when creating work, and use ?app=<product>&project=<id>
to list one project's records.
- Conformance goals — a key outcome plus the test (
proof) that would prove it met;open→met. - Kanban tasks — units of work positioned by
columnandrank; move them across the board. - Decisions — the critical/technical decisions made while building, full-text searchable.
- Bugs — reported defects and missing pieces;
open→triaged→fixed/wontfix.triagedis the one status with a bar to clear: it means someone decided how bad and who carries it, so it requires aseverityplus anassigneeIdor agoalId, enforced on write. Without that rule it was a label anyone could apply to nothing, which is exactly what it kept being. The other three states are self-evident from the record, so they are ungated.
Who filed it
Ownership and authorship are deliberately separate:
ownerId/ownerEmail— the accountable human account used for project ownership.principalId/principalKind— the actual credential-bound human, agent, or service that acted.credentialId/credentialKind,managerPrincipalId,delegatedByPrincipalId,grantId, andgrantVersion— the credential and authority provenance available for audit.agentId/agentLabel— a compatibility projection when that authenticated principal is an agent.lastEditedByPrincipalId/lastEditedByPrincipalKindplus the corresponding credential, manager, delegator, grant revision, and label fields — the actual author and authority behind the record's current contents. Creation provenance above remains immutable.
The registry supplies this context through PmOperator; PmClient only sends
the bearer credential. The legacy x-odla-agent-id and
x-odla-agent-label headers are rejected with
actor_spoof_forbidden, including when empty. An agent cannot rename itself,
claim another principal, or turn manager metadata into authority.
For app-scoped actions, the registry implements
PmAuthz.resolveAppAuthority: it resolves the live exact/wildcard project grant
immediately before the action and stamps that grant's id, version, and grantor.
A revoked grant denies the next action. Platform runbook capability writes keep
their credential provenance and do not fabricate a project grant.
PM task authority is deliberately split at that boundary:
pm.readobserves current state and the work journal.pm.writecovers general PM mutation and remains executable for compatibility with grants issued before the split.pm.planincludes read/write, task refinement and deletion, and the reviewed transition into Ready; it does not permit claim or active execution.pm.executeincludes read/write, Backlog proposals and findings, atomic claim/release, and claimed Doing → Review → Done transitions; it does not permit contract refinement or Ready approval.
Ordinary managed-agent handshakes receive pm.execute, not pm.plan. A human
owner or deliberately authorized planner must make the durable Ready handoff.
A mixed task patch is treated as planning, so an executor cannot attach a
contract edit to an otherwise executable column change.
Trusted in-process code may construct PmSelf directly. At that seam the caller
is the host: do not populate it from untrusted request fields.
What changed lately
List routes take ?since=hour|day|week, narrowing to records whose updatedAt
falls inside the window and ordering them most-recently-touched first. This tracks
record changes — a field edit, or the create itself; discussion comments live in
@odla-ai/chat and do not bump the record. Status-style filters also accept a
comma-separated set (?status=open,triaged), which is how the bugs view asks for
everything still wrong in one round trip.
Views are the subscription
A PM view renders straight off a WebSocket onto the shared odla-pm tenant.
Rows and their pagination totals arrive by push; nothing polls.
Pass the suite a reactive query hook bound to that tenant and it uses it:
<OdlaProvider db={pmDb}>
<PmSuite client={api.pm} useQuery={useQuery} projects={projects} />
</OdlaProvider>Omit useQuery and the same views fall back to the REST route on a timer, so the
kit still works for a host without a socket.
Bridging a record into Discussion
Pass onDiscuss to PmSuite to expose a Discuss action beside Copy
reference. The callback receives a PmDiscussionTarget with the exact project,
record kind/id, current title, and copy-ready pm:* markup. A host can use that
identity to open a scoped Discussion composer; PmSuite never posts or assumes
Discussion authority itself.
Studio carries that target as bounded URL draft state and preloads a structured reference into Discussion. Posting remains an explicit viewer action. This is a navigation/authoring bridge, not a canonical one-topic-per-record migration: PM-attached comments and general Discussion keep their independent capability checks and event streams.
Durable agent work events
Every goal, task, decision, and bug create/update/delete appends an immutable
pm_event snapshot in the same odla-db transaction. Operational runbooks keep
their separate visibility-sensitive history. GET /registry/pm/work/watch?app=...
projects those rows through the database change feed with an opaque resumable
cursor, stable event ids, at-least-once delivery, and an explicit
checkpoint_required response when history can no longer resume safely.
Use PmClient.watchWork(appId, cursor) for one page, the PM agent skill's
watch_work tool for supervisor-driven page reads, or odla-ai pm watch
--app <id> --jsonl for a long-running process. The first read establishes a
checkpoint rather than replaying history. Event delivery is a signal to reload
authoritative current state; a task still requires a winning revisioned claim
before execution.
The same cursor also delivers *.comment.created/*.comment.updated for chat
messages whose channel or topic root correlates to an active goal, task,
decision, or bug. General project topics and mentions remain on the independent
Discussion watch: pm.read and discussion.read are distinct capabilities, so
merging those rows before Discussion has context-aware authorization would leak
one domain through the other. Visibility-sensitive runbook comments remain off
the work stream for the same reason. Deleting a work item removes its current row
but deliberately retains the authorized deletion snapshot until the exact
project lifetime is purged.
How the socket is scoped
The browser holds no credential for odla-pm and must not — the tenant carries
every project's PM rows, and only the registry knows which projects an operator
co-owns. POST /registry/pm/live-ticket resolves that and signs it into a
short-lived scoped ticket: the DO accepts it as a rules-enforced session
(never admin), and the tenant's CEL rules filter every row against the claims:
view: data.projectScope in auth.claims.projectScopesThe claims are covered by the signature, so a holder cannot widen their own scope. The exact scope contains the Registry incarnation, so a ticket for lifetime A cannot read replacement lifetime B even when both share an app id. Authorship is not an authority bypass: a removed collaborator's fresh empty ticket cannot keep reading rows they created. Archived lifetimes stay in read claims while their writes are suspended. Tickets are minted per (re)connect, so ownership and archive changes take effect on the next connect. The registry installs these rules itself when they differ from the running build's.
Reads are live; writes still go through the route
Writes stay on PmClient; every rules-enforced create/update/delete is false.
Each route write resolves credential-bound authority for that exact project
lifetime: a human through current ownership, or a managed agent through its
current capability grant. Only then does it create or atomically normalize the
exact lifetime's open-write fence. REST reads only inspect an existing lifetime:
a retained legacy read with no lifetime remains read-only, while a mismatched or
tombstoned lifetime fails closed. It also performs side effects (a status change
posts to the item's @odla-ai/chat thread) that a direct transact would skip. The
socket then delivers the result, so a write still repaints on its own — the
write path just isn't the read path run backwards.
Runbook revisions are deliberately not readable over this channel: they carry no
visibility of their own and there are no links for a rule to reach their parent
runbook, so a claims-based read would expose an admin-visibility runbook's body
through its history. They stay on the REST route, which checks the parent.
Runbook body writes also carry expectedVersion, the exact version the human or
agent reviewed. The prior-body snapshot and replacement commit in one guarded
transaction; if another collaborator wins first, the stale write returns 409 and
creates no orphan revision. The Studio editor, CLI, and amend_runbook tool all
forward the version they actually read.
Shared across the projects you can access
Every PM record carries an exact project scope (logical app id plus Registry
incarnation) and lives in one shared odla-db tenant. Humans receive access
through current ownership; managed agents receive only the explicit
pm.read/pm.write/pm.plan/pm.execute project grants attached to their
credential-bound principal. The Registry resolves that action-specific
authority at each REST action, while a signed live ticket carries only the exact
read scopes authorized at connect time. That keeps a cross-project board
convenient without turning the shared store into shared authority.
Quick start
import {
createBug,
listBugs,
type PmDb,
type PmSelf,
} from "@odla-ai/pm";
import type { ProjectScope } from "@odla-ai/chat";
type TrustedActor = PmSelf & { project: ProjectScope };
// The trusted Registry host injects these values; browsers and agents never
// receive its DB_ADMIN_SECRET or a direct odla-pm tenant key.
export async function quickStart(
db: PmDb,
self: TrustedActor,
readableProjects: ProjectScope[],
) {
const { id } = await createBug(
{ db },
{
appId: self.project.appId,
self,
input: {
title: "Checkout 500s on retry",
description: "A second submit after a timeout returns 500; request id …",
severity: "high",
},
},
);
const { records } = await listBugs(
{ db },
{ apps: readableProjects, status: "open" },
);
return { id, records };
}For agents
import { pmSkill, type PmDb, type PmSelf } from "@odla-ai/pm";
import type { ProjectScope } from "@odla-ai/chat";
// Attach to a @odla-ai/ai Persona; the trusted host supplies the exact project.
export function projectSkill(
db: PmDb,
self: PmSelf & { project: ProjectScope },
) {
return pmSkill({ db, self, appId: self.project.appId });
}
// add_goal, mark_goal_met, list_goals, get_goal,
// add_task, list_tasks, get_task, mark_task_ready, claim_task, release_task, move_task,
// report_bug, resolve_bug, list_bugs, record_decision, search_decisions,
// project_handoffproject_handoff returns the project's unmet goals, active tasks, and open bugs
directly from PM. Bug descriptions are required. A bug may link an accepted
same-project decision with decisionId, making an accepted delta or retained
risk explicit instead of leaving a contradictory open title behind. Goals
cannot be marked met while linked tasks or bugs remain unresolved.
todo is the stable storage value for the user-facing Ready state. Entering
it requires an open same-project goal or accepted alignment decision, an
actionable description, and acceptance criteria. Tasks carry a monotonic
revision. The REST boundary additionally requires planning authority for
Ready. Agents start work with claimTask/claim_task, which compares the
reviewed revision and atomically assigns the authenticated principal while
moving to doing; releaseTask returns an owned claim to Ready. Blind agent
moves into Doing are rejected.
Pieces
PM_SCHEMA/PM_RULES— the odla-db schema + default-deny rules for the shared tenant.pmTenantSchema(installed?)— the non-destructive merged PM + Discussion schema builder for fresh installs and legacy numeric-timestamp tenants.createGoal/createTask/createDecision/createBug(+get/update/list/status verbs) — typed ops.markTaskReady/claimTask/releaseTask— revision-guarded work intake and ownership transitions.createPmRoutes({ db, authz })— the mountable route factory the odla-ai registry composes.PmClient— a typed fetch wrapper for the CLI and Studio UI.pmSkill— the agent skill;ensureItemChannel/comment/listMessages— chat-backed discussion.pmIntegration— documentation-as-data for provisioning: one merged PM + Discussion schema intenantSchema, plus the scopedtenantRulesRegistry reconciles over its exact-lifetime DB binding before/registry/pm/live-ticket. Its legacy fragment fields remain only for compatibility. Do not install plainCHAT_RULESor mint a direct shared-tenant key.
See the exported TypeScript declarations / JSDoc, and the rendered reference at https://odla.ai/docs/packages/pm.
Agentic experiment. odla is an experiment in software built primarily by agents. Treat it accordingly.
License
MIT
