Skip to content

Operations ​

Every change to a plan is a batch of operations from one actor:

ts
import { applyPlanOps } from '@adecore/plan';

const result = applyPlanOps(plan, [{ op: 'set', ids: ['tests'], state: 'done', next: 'publish' }], {
    actor: 'agent',
    now: new Date().toISOString()
});

if (result.ok) {
    save(result.plan);
} else {
    reply(result.code, result.message);
}

applyPlanOps(plan, ops, options) works on a copy. Each operation sees what the ones before it did, and the batch is all or nothing: one refusal and the plan you passed in is returned untouched. A batch that passes raises rev by exactly one, so a step marked done and the next one marked active land in the same revision. The result is checked like a loaded plan before it is returned.

options is a PlanApplyOptions: the actor, the now written into at, and an optional mintId for new items without an id. A success is a PlanApplied, { ok: true, plan, minted, dropped }: minted lists the ids made for new items and dropped the steps that lost their state because they got their first sub-step.

The operations ​

opFieldsDoes
setids, state; optional note, nextSets the state of leaf steps. next marks one more step active.
noteid, textWrites the note of a step. An empty text clears it.
unlockids, or 'all'Makes every step only the agent checks, in those subtrees, a step anyone checks.
addtype, title; optional id, description, checks, under, afterAdds a section, text block or step.
editid; optional title, description, checksChanges an item. An empty description removes it.
moveid; optional under, afterMoves an item with everything under it.
removeidRemoves an item with everything under it.
metaOptional title, summary, status, checksChanges the plan's meta. An empty summary or status removes it.

under names the section or step to put an item in, and after the item it follows there. Without either the item goes last at the top; with only after it goes right after that item, beside it. A section only stands at the top, a text block not under a step, and nothing moves into itself.

A set only takes leaf steps: a parent's state follows from its children. Several active steps at once are fine; next does not end the others. When a person sets a step back to open, by and at go away, so a misclick does not leave a mark the agent has to respect. Adding the first sub-step to a step clears its state, as its children now decide it, and lists it in dropped.

PlanOpSchema parses any operation and PlanPersonOpSchema only the three a person may send.

Permissions ​

A person sends set, note and unlock. The agent sends everything except unlock: building the plan is the agent's job, and only a person can lift a lock.

Who may set a step depends on its checks:

ChecksA personThe agent
anyoneYesYes, unless a person set it
agentNot until a person unlocks itYes, unless a person set it
personYesNever

A state a person set belongs to the person. The agent cannot change it, though it may set the same state again, which keeps the person's mark. Nor can the agent remove the step or anything holding it, rename it, or add a sub-step that would take its state away. A step only a person checks keeps its title and its checks and cannot be removed by the agent, even before anyone checked it, and meta.checks stays person while a step still takes its checks from it. Once a person unlocks a step, the agent cannot lock it again. Notes, descriptions and moves stay open to the agent.

canApply(op, actor, plan) answers the permission question for one operation without applying it, to grey out a control. Whether the result is a valid plan is only known after applyPlanOps. canDeletePlan(plan, actor) refuses an agent that would delete a plan holding a state a person set. Deleting a plan is the app's to do.

Refusals ​

A refusal is a PlanRefusal, { ok: false, code, message }. The message names the item and reads well enough to pass to an agent, which can correct its next batch from it. PlanVerdict is a refusal or { ok: true }, and refuse(code, message) builds one for the app's own checks.

PlanRefusalCodeWhen
op-not-allowedThe actor may not send this operation, or a person sent next
step-lockedA person set a step only the agent checks
person-onlyThe agent touched a step only a person checks
set-by-personThe agent would change or remove what a person set
unlocked-by-personThe agent tried to lock a step a person unlocked
plan-missing-itemAn id names no item
plan-not-a-stepA step operation named a section or text block
plan-parent-stateA state was set on, or stored on, a step with sub-steps
plan-bad-positionunder or after puts an item where it cannot stand
duplicate-idAn id is taken
plan-too-deepSteps nest deeper than 5
plan-too-largeThe plan holds more than 300 items
plan-invalidThe operations, the draft or the result do not fit the schema
plan-not-foundNot returned by the package: for an app that looks plans up
too-many-plansNot returned by the package: for an app that limits plans per conversation

In a store ​

Because operations carry no base revision, a store applies each batch to the latest plan inside one transaction, or one queue per plan:

ts
import { applyPlanOps, refuse, type PlanApplied, type PlanRefusal } from '@adecore/plan';
import type { PlanActor, PlanOp } from '@adecore/plan/protocol';

async function update(id: string, ops: PlanOp[], actor: PlanActor): Promise<PlanApplied | PlanRefusal> {
    return store.transaction(async (latest) => {
        const plan = await latest(id);
        return plan ? applyPlanOps(plan, ops, { actor, now: new Date().toISOString() }) : refuse('plan-not-found', `No plan "${id}"`);
    });
}

store stands for the app's own storage, which saves result.plan only when the result is ok. Tell the page about the change after the commit, not before. A custom mintId may be called for a batch that is refused later, so it must not write anything.

Licenses and third-party notices are listed in each package.