@adecore/plan 0.0.1 → 0.17.0-beta.2

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.
@@ -0,0 +1,275 @@
1
+ import type { Plan, PlanItem, PlanStep, PlanStepState } from './protocol.ts';
2
+ import type { PlanDraft, PlanDraftItem, PlanDraftSection, PlanDraftStep, PlanDraftText } from './apply.ts';
3
+ import { PLAN_STATE_MARKERS } from './text.ts';
4
+ import { refuse, stepState, type PlanRefusal } from './tree.ts';
5
+
6
+ const INDENT = ' ';
7
+
8
+ const MARKER_STATES: Record<string, PlanStepState | undefined> = {
9
+ ' ': undefined,
10
+ x: 'done',
11
+ X: 'done',
12
+ '~': 'active',
13
+ '!': 'failed',
14
+ '-': 'skipped',
15
+ '?': 'blocked',
16
+ w: 'warning',
17
+ W: 'warning',
18
+ i: 'info',
19
+ I: 'info'
20
+ };
21
+
22
+ /* What the reader takes for a heading, a rule, a list item or a quote at the start of a line, and the escape itself. */
23
+ const MARKUP = /^(#{1,6}\s|(-{3,}|\*{3,}|_{3,})$|[-*+]\s|>|\\)/;
24
+
25
+ function escapeLine(line: string): string {
26
+ return MARKUP.test(line.trimStart()) ? `\\${line}` : line;
27
+ }
28
+
29
+ function escapeText(text: string): string {
30
+ return text.split('\n').map(escapeLine).join('\n');
31
+ }
32
+
33
+ function escapeBold(title: string): string {
34
+ return title.replace(/[\\*]/g, '\\$&');
35
+ }
36
+
37
+ /* A later line of a text block that starts in bold would start a text block of its own. */
38
+ function escapeQuoted(line: string): string {
39
+ return /^(\*\*|\\)/.test(line.trimStart()) ? `\\${line}` : line;
40
+ }
41
+
42
+ function dropEscape(line: string): string {
43
+ return line.trimStart().startsWith('\\') ? line.replace('\\', '') : line;
44
+ }
45
+
46
+ /*
47
+ * A plan as a GFM task list: `#` the title, `##` a section, `> **Title**` a text block, `- [ ]` a
48
+ * step with its sub-steps indented under it. Beyond `[ ]` and `[x]` a step carries the markers of
49
+ * `plan read`, and a `>` line under a step is its note. Ids, who set a state and the locks stay out.
50
+ * A line of text the reader would take for markup starts with a backslash, which the reader drops.
51
+ */
52
+ export function planToMarkdown(plan: Plan): string {
53
+ const blocks: string[] = [`# ${plan.meta.title}`];
54
+ if (plan.meta.summary) {
55
+ blocks.push(escapeText(plan.meta.summary));
56
+ }
57
+ const stepLines = (step: PlanStep, depth: number): string[] => {
58
+ const indent = INDENT.repeat(depth);
59
+ const inner = INDENT.repeat(depth + 1);
60
+ const lines = [`${indent}- ${PLAN_STATE_MARKERS[stepState(step)]} ${step.title}`];
61
+ for (const line of step.description?.split('\n') ?? []) {
62
+ lines.push(line === '' ? '' : `${inner}${escapeLine(line)}`);
63
+ }
64
+ for (const line of step.note?.split('\n') ?? []) {
65
+ lines.push(`${inner}> ${line}`);
66
+ }
67
+ for (const child of step.steps ?? []) {
68
+ lines.push(...stepLines(child, depth + 1));
69
+ }
70
+ return lines;
71
+ };
72
+ const textBlock = (item: Extract<PlanItem, { type: 'text' }>): string => {
73
+ const [first = '', ...rest] = item.description?.split('\n') ?? [];
74
+ return [`> **${escapeBold(item.title)}**${first ? ` ${first}` : ''}`, ...rest.map((line) => `> ${escapeQuoted(line)}`)].join('\n');
75
+ };
76
+ const itemBlocks = (items: readonly PlanItem[]): string[] => {
77
+ const result: string[] = [];
78
+ let list: string[] = [];
79
+ const flush = (): void => {
80
+ if (list.length > 0) {
81
+ result.push(list.join('\n'));
82
+ list = [];
83
+ }
84
+ };
85
+ for (const item of items) {
86
+ if (item.type === 'step') {
87
+ list.push(...stepLines(item, 0));
88
+ } else if (item.type === 'text') {
89
+ flush();
90
+ result.push(textBlock(item));
91
+ }
92
+ }
93
+ flush();
94
+ return result;
95
+ };
96
+ let inSection = false;
97
+ let loose: PlanItem[] = [];
98
+ const flushLoose = (): void => {
99
+ if (loose.length > 0) {
100
+ // Only a rule brings the items after a section back to the top of the plan.
101
+ if (inSection) {
102
+ blocks.push('---');
103
+ inSection = false;
104
+ }
105
+ blocks.push(...itemBlocks(loose));
106
+ loose = [];
107
+ }
108
+ };
109
+ for (const item of plan.items) {
110
+ if (item.type !== 'section') {
111
+ loose.push(item);
112
+ continue;
113
+ }
114
+ flushLoose();
115
+ blocks.push(`## ${item.title}`);
116
+ if (item.description) {
117
+ blocks.push(escapeText(item.description));
118
+ }
119
+ blocks.push(...itemBlocks(item.items));
120
+ inSection = true;
121
+ }
122
+ flushLoose();
123
+ return `${blocks.join('\n\n')}\n`;
124
+ }
125
+
126
+ function indentOf(line: string): number {
127
+ let width = 0;
128
+ for (const char of line) {
129
+ if (char === ' ') {
130
+ width++;
131
+ } else if (char === '\t') {
132
+ width += 4;
133
+ } else {
134
+ break;
135
+ }
136
+ }
137
+ return width;
138
+ }
139
+
140
+ function appendLine(value: string | undefined, line: string, blanks = 0): string {
141
+ return value === undefined ? line : `${value}\n${'\n'.repeat(blanks)}${line}`;
142
+ }
143
+
144
+ /*
145
+ * Reads a GFM task list into a draft for `plan new`. Lenient where Markdown is (a list item without a
146
+ * checkbox is an open step), strict where a line cannot belong to anything.
147
+ */
148
+ export function parsePlanMarkdown(markdown: string): { ok: true; draft: PlanDraft } | PlanRefusal {
149
+ const draft: PlanDraft = { items: [] };
150
+ let section: PlanDraftSection | null = null;
151
+ let stack: { indent: number; step: PlanDraftStep }[] = [];
152
+ let text: PlanDraftText | null = null;
153
+ // The text the last line of prose went on and the blank lines since, so a paragraph break inside it stays.
154
+ let continuing: object | null = null;
155
+ let blanks = 0;
156
+ const container = (): PlanDraftItem[] => (section ? section.items : draft.items) as PlanDraftItem[];
157
+ const popTo = (indent: number): void => {
158
+ while (stack.length > 0 && stack[stack.length - 1]!.indent >= indent) {
159
+ stack.pop();
160
+ }
161
+ };
162
+ const lines = markdown.replace(/\r\n?/g, '\n').split('\n');
163
+ for (let index = 0; index < lines.length; index++) {
164
+ const raw = lines[index]!.trimEnd();
165
+ const content = raw.trim();
166
+ const indent = indentOf(raw);
167
+ const invalid = (message: string): PlanRefusal => refuse('plan-invalid', `Line ${index + 1}: ${message}`);
168
+ if (content === '') {
169
+ text = null;
170
+ blanks++;
171
+ continue;
172
+ }
173
+ const previous = continuing;
174
+ const gap = blanks;
175
+ continuing = null;
176
+ blanks = 0;
177
+ const blanksBefore = (target: object): number => {
178
+ continuing = target;
179
+ return previous === target ? gap : 0;
180
+ };
181
+ const plain = dropEscape(content);
182
+ const heading = /^(#{1,6})\s+(.*)$/.exec(content);
183
+ if (heading && indent < 4) {
184
+ text = null;
185
+ stack = [];
186
+ const title = heading[2]!.trim();
187
+ if (heading[1] === '#' && draft.meta?.title === undefined && draft.items.length === 0 && !section) {
188
+ draft.meta = { ...draft.meta, title };
189
+ } else if (heading[1] === '##') {
190
+ section = { type: 'section', title, items: [] };
191
+ draft.items.push(section);
192
+ } else {
193
+ return invalid('only one "#" title before everything else and "##" sections are headings in a plan');
194
+ }
195
+ continue;
196
+ }
197
+ if (/^(-{3,}|\*{3,}|_{3,})$/.test(content) && indent < 4) {
198
+ section = null;
199
+ stack = [];
200
+ text = null;
201
+ continue;
202
+ }
203
+ const item = /^[-*+]\s+(?:\[(.)\]\s+)?(.*)$/.exec(content);
204
+ if (item) {
205
+ text = null;
206
+ const marker = item[1];
207
+ if (marker !== undefined && !(marker in MARKER_STATES)) {
208
+ return invalid(`"[${marker}]" is not a step marker`);
209
+ }
210
+ const title = item[2]!.trim();
211
+ if (title === '') {
212
+ return invalid('a step needs a title');
213
+ }
214
+ const state = marker === undefined ? undefined : MARKER_STATES[marker];
215
+ const step: PlanDraftStep = { type: 'step', title, ...(state ? { state } : {}) };
216
+ popTo(indent);
217
+ const parent = stack[stack.length - 1]?.step;
218
+ if (parent) {
219
+ parent.steps ??= [];
220
+ parent.steps.push(step);
221
+ } else {
222
+ container().push(step);
223
+ }
224
+ stack.push({ indent, step });
225
+ continue;
226
+ }
227
+ const quote = /^>\s?(.*)$/.exec(content);
228
+ popTo(indent);
229
+ const owner = stack[stack.length - 1]?.step;
230
+ if (owner && indent > 0) {
231
+ if (quote) {
232
+ owner.note = appendLine(owner.note, quote[1]!);
233
+ } else {
234
+ owner.description = appendLine(owner.description, plain, blanksBefore(owner));
235
+ }
236
+ continue;
237
+ }
238
+ stack = [];
239
+ if (quote) {
240
+ const titled = /^\*\*((?:\\.|[^\\])+?)\*\*\s*(.*)$/.exec(quote[1]!.trim());
241
+ if (titled) {
242
+ const title = titled[1]!.trim().replace(/\\([\\*])/g, '$1');
243
+ text = { type: 'text', title, ...(titled[2] ? { description: titled[2] } : {}) };
244
+ container().push(text);
245
+ } else if (text) {
246
+ text.description = appendLine(text.description, dropEscape(quote[1]!));
247
+ } else {
248
+ return invalid('a text block starts with a bold title: > **Title** description');
249
+ }
250
+ continue;
251
+ }
252
+ if (!section && draft.items.length === 0) {
253
+ draft.meta = { ...draft.meta, summary: appendLine(draft.meta?.summary, plain, blanksBefore(draft)) };
254
+ continue;
255
+ }
256
+ if (section && section.items.length === 0) {
257
+ section.description = appendLine(section.description, plain, blanksBefore(section));
258
+ continue;
259
+ }
260
+ return invalid('this line is not a step, a section, a text block or a description');
261
+ }
262
+ // A parent's state follows from its children, so a marker on it says nothing.
263
+ const clearParents = (items: readonly PlanDraftItem[]): void => {
264
+ for (const entry of items) {
265
+ if (entry.type === 'section') {
266
+ clearParents(entry.items);
267
+ } else if (entry.type === 'step' && entry.steps) {
268
+ delete entry.state;
269
+ clearParents(entry.steps);
270
+ }
271
+ }
272
+ };
273
+ clearParents(draft.items);
274
+ return { ok: true, draft };
275
+ }
@@ -0,0 +1,162 @@
1
+ import type { Plan, PlanActor, PlanOp, PlanStep, PlanStepState } from './protocol.ts';
2
+ import { allSteps, effectiveChecks, holdsPersonState, isParentStep, locateItem, refuse, type PlanRefusal } from './tree.ts';
3
+
4
+ export type PlanVerdict = { ok: true } | PlanRefusal;
5
+
6
+ const ALLOWED: PlanVerdict = { ok: true };
7
+
8
+ const PERSON_OPS: ReadonlySet<PlanOp['op']> = new Set(['set', 'note', 'unlock']);
9
+ const AGENT_OPS: ReadonlySet<PlanOp['op']> = new Set(['set', 'note', 'add', 'edit', 'move', 'remove', 'meta']);
10
+
11
+ function stepFor(plan: Plan, id: string, leaf: boolean): PlanStep | PlanRefusal {
12
+ const location = locateItem(plan, id);
13
+ if (!location) {
14
+ return refuse('plan-missing-item', `The plan has no item "${id}"`);
15
+ }
16
+ if (location.item.type !== 'step') {
17
+ return refuse('plan-not-a-step', `"${id}" is a ${location.item.type}, not a step`);
18
+ }
19
+ if (leaf && isParentStep(location.item)) {
20
+ return refuse('plan-parent-state', `The step "${id}" has sub-steps, so its state follows from them`);
21
+ }
22
+ return location.item;
23
+ }
24
+
25
+ function mayState(plan: Plan, step: PlanStep, state: PlanStepState, actor: PlanActor): PlanVerdict {
26
+ const checks = effectiveChecks(plan, step);
27
+ if (actor === 'person') {
28
+ return checks === 'agent' ? refuse('step-locked', `Only the agent checks "${step.id}" until a person unlocks it`) : ALLOWED;
29
+ }
30
+ if (checks === 'person') {
31
+ return refuse('person-only', `Only a person checks "${step.id}"`);
32
+ }
33
+ if (step.by === 'person' && step.state !== undefined && step.state !== state) {
34
+ return refuse('set-by-person', `A person set "${step.id}" to ${step.state}; add a note or ask in the chat instead`);
35
+ }
36
+ return ALLOWED;
37
+ }
38
+
39
+ /* Adding under a leaf takes its state away, which may not happen to a state a person set. */
40
+ function mayHoldChildren(plan: Plan, under: string | undefined): PlanVerdict {
41
+ if (under === undefined) {
42
+ return ALLOWED;
43
+ }
44
+ const location = locateItem(plan, under);
45
+ if (location?.item.type === 'step' && !isParentStep(location.item) && holdsPersonState(location.item)) {
46
+ return refuse('set-by-person', `A person set "${under}" to ${location.item.state}; a sub-step would take that state away`);
47
+ }
48
+ return ALLOWED;
49
+ }
50
+
51
+ /*
52
+ * Whether this actor may apply the operation to the plan as it is now: who sets which step, what a
53
+ * person set, what a person unlocked. The shape of the result (depth, size, positions) is checked
54
+ * when the operation is applied.
55
+ */
56
+ export function canApply(op: PlanOp, actor: PlanActor, plan: Plan): PlanVerdict {
57
+ if (!(actor === 'person' ? PERSON_OPS : AGENT_OPS).has(op.op)) {
58
+ return refuse('op-not-allowed', actor === 'person' ? `A person cannot ${op.op} in a plan` : `An agent cannot ${op.op} a plan`);
59
+ }
60
+ switch (op.op) {
61
+ case 'set': {
62
+ if (op.next !== undefined && actor === 'person') {
63
+ return refuse('op-not-allowed', 'Only the agent moves on to a next step');
64
+ }
65
+ const targets: [string, PlanStepState][] = op.ids.map((id) => [id, op.state]);
66
+ if (op.next !== undefined) {
67
+ targets.push([op.next, 'active']);
68
+ }
69
+ for (const [id, state] of targets) {
70
+ const step = stepFor(plan, id, true);
71
+ if ('ok' in step) {
72
+ return step;
73
+ }
74
+ const verdict = mayState(plan, step, state, actor);
75
+ if (!verdict.ok) {
76
+ return verdict;
77
+ }
78
+ }
79
+ return ALLOWED;
80
+ }
81
+ case 'note': {
82
+ const step = stepFor(plan, op.id, false);
83
+ return 'ok' in step ? step : ALLOWED;
84
+ }
85
+ case 'unlock': {
86
+ if (op.ids === 'all') {
87
+ return ALLOWED;
88
+ }
89
+ for (const id of op.ids) {
90
+ const step = stepFor(plan, id, false);
91
+ if ('ok' in step) {
92
+ return step;
93
+ }
94
+ }
95
+ return ALLOWED;
96
+ }
97
+ case 'add':
98
+ return mayHoldChildren(plan, op.under);
99
+ case 'edit': {
100
+ const location = locateItem(plan, op.id);
101
+ if (!location) {
102
+ return refuse('plan-missing-item', `The plan has no item "${op.id}"`);
103
+ }
104
+ const item = location.item;
105
+ if (item.type !== 'step') {
106
+ return ALLOWED;
107
+ }
108
+ // A new title would make a person's check or stamp stand for something they never saw.
109
+ if (op.title !== undefined && op.title !== item.title) {
110
+ if (holdsPersonState(item)) {
111
+ return refuse('set-by-person', `A person checked "${op.id}" or a step under it, so its title stays`);
112
+ }
113
+ if (effectiveChecks(plan, item) === 'person') {
114
+ return refuse('person-only', `Only a person checks "${op.id}", so its title stays`);
115
+ }
116
+ }
117
+ if (op.checks === undefined) {
118
+ return ALLOWED;
119
+ }
120
+ if (item.unlocked && op.checks !== 'anyone') {
121
+ return refuse('unlocked-by-person', `A person unlocked "${op.id}", so anyone checks it`);
122
+ }
123
+ if (effectiveChecks(plan, item) === 'person' && op.checks !== 'person') {
124
+ return refuse('person-only', `Only a person checks "${op.id}", and that stays so`);
125
+ }
126
+ return ALLOWED;
127
+ }
128
+ case 'move': {
129
+ if (!locateItem(plan, op.id)) {
130
+ return refuse('plan-missing-item', `The plan has no item "${op.id}"`);
131
+ }
132
+ return mayHoldChildren(plan, op.under);
133
+ }
134
+ case 'remove': {
135
+ const location = locateItem(plan, op.id);
136
+ if (!location) {
137
+ return refuse('plan-missing-item', `The plan has no item "${op.id}"`);
138
+ }
139
+ if (holdsPersonState(location.item)) {
140
+ return refuse('set-by-person', `A person checked "${op.id}" or a step under it, so it stays`);
141
+ }
142
+ if (allSteps([location.item]).some((step) => effectiveChecks(plan, step) === 'person')) {
143
+ return refuse('person-only', `Only a person checks "${op.id}" or a step under it, so it stays`);
144
+ }
145
+ return ALLOWED;
146
+ }
147
+ case 'meta': {
148
+ const inherits = allSteps(plan.items).some((step) => step.checks === undefined && !step.unlocked);
149
+ if (op.checks !== undefined && op.checks !== 'person' && plan.meta.checks === 'person' && inherits) {
150
+ return refuse('person-only', 'Only a person checks the steps of this plan, and that stays so');
151
+ }
152
+ return ALLOWED;
153
+ }
154
+ }
155
+ }
156
+
157
+ /* Deleting a plan takes every check a person made in it along, so an agent leaves one that holds any. */
158
+ export function canDeletePlan(plan: Plan, actor: PlanActor): PlanVerdict {
159
+ return actor === 'agent' && plan.items.some(holdsPersonState)
160
+ ? refuse('set-by-person', `A person checked steps of "${plan.meta.title}", so the plan stays`)
161
+ : ALLOWED;
162
+ }
@@ -0,0 +1,194 @@
1
+ import { z } from 'zod';
2
+
3
+ // Depth and item count require the whole tree and are checked by validatePlan.
4
+ export const PLAN_LIMITS = {
5
+ idLength: 64,
6
+ title: 200,
7
+ description: 1000,
8
+ note: 500,
9
+ status: 200,
10
+ depth: 5,
11
+ items: 300,
12
+ plansPerChat: 20
13
+ } as const;
14
+
15
+ export const PlanItemIdSchema = z
16
+ .string()
17
+ .min(1)
18
+ .max(PLAN_LIMITS.idLength)
19
+ .regex(/^[a-z0-9-]+$/);
20
+ export type PlanItemId = z.infer<typeof PlanItemIdSchema>;
21
+
22
+ export const PlanIdSchema = PlanItemIdSchema;
23
+ export type PlanId = z.infer<typeof PlanIdSchema>;
24
+
25
+ // Persisted consumers validate the entire enum; new fields must remain optional.
26
+ export const PlanStepStateSchema = z.enum(['open', 'active', 'done', 'failed', 'skipped', 'blocked', 'warning', 'info']);
27
+ export type PlanStepState = z.infer<typeof PlanStepStateSchema>;
28
+
29
+ export const PlanChecksSchema = z.enum(['anyone', 'agent', 'person']);
30
+ export type PlanChecks = z.infer<typeof PlanChecksSchema>;
31
+
32
+ export const PlanActorSchema = z.enum(['person', 'agent']);
33
+ export type PlanActor = z.infer<typeof PlanActorSchema>;
34
+
35
+ export const PlanKindSchema = z.enum(['steps', 'test']);
36
+ export type PlanKind = z.infer<typeof PlanKindSchema>;
37
+
38
+ const TitleSchema = z.string().min(1).max(PLAN_LIMITS.title);
39
+ const DescriptionSchema = z.string().max(PLAN_LIMITS.description);
40
+ const NoteSchema = z.string().max(PLAN_LIMITS.note);
41
+
42
+ export const PlanMetaSchema = z.object({
43
+ title: TitleSchema,
44
+ kind: PlanKindSchema,
45
+ summary: DescriptionSchema.optional(),
46
+ // One line under the title, for a plan that tracks progress.
47
+ status: z.string().max(PLAN_LIMITS.status).optional(),
48
+ // Who sets a step that names no `checks` of its own.
49
+ checks: PlanChecksSchema
50
+ });
51
+ export type PlanMeta = z.infer<typeof PlanMetaSchema>;
52
+
53
+ export const PlanStepSchema = z.object({
54
+ type: z.literal('step'),
55
+ id: PlanItemIdSchema,
56
+ title: TitleSchema,
57
+ description: DescriptionSchema.optional(),
58
+ checks: PlanChecksSchema.optional(),
59
+ // Only on a step without sub-steps; a parent derives its state from its children.
60
+ state: PlanStepStateSchema.optional(),
61
+ // Who set the state last and when, filled by the host and never by an agent.
62
+ by: PlanActorSchema.optional(),
63
+ at: z.string().optional(),
64
+ note: NoteSchema.optional(),
65
+ // A person lifted the lock, so the step is `anyone` from then on and an agent cannot lock it again.
66
+ unlocked: z.boolean().optional(),
67
+ get steps() {
68
+ return z.array(PlanStepSchema).optional();
69
+ }
70
+ });
71
+ export type PlanStep = z.infer<typeof PlanStepSchema>;
72
+
73
+ export const PlanTextSchema = z.object({
74
+ type: z.literal('text'),
75
+ id: PlanItemIdSchema,
76
+ title: TitleSchema,
77
+ description: DescriptionSchema.optional()
78
+ });
79
+ export type PlanText = z.infer<typeof PlanTextSchema>;
80
+
81
+ // Sections do not nest: they only stand at the top of a plan.
82
+ export const PlanSectionSchema = z.object({
83
+ type: z.literal('section'),
84
+ id: PlanItemIdSchema,
85
+ title: TitleSchema,
86
+ description: DescriptionSchema.optional(),
87
+ items: z.array(z.discriminatedUnion('type', [PlanTextSchema, PlanStepSchema]))
88
+ });
89
+ export type PlanSection = z.infer<typeof PlanSectionSchema>;
90
+
91
+ export const PlanItemSchema = z.discriminatedUnion('type', [PlanSectionSchema, PlanTextSchema, PlanStepSchema]);
92
+ export type PlanItem = z.infer<typeof PlanItemSchema>;
93
+
94
+ export const PlanSchema = z.object({
95
+ id: PlanIdSchema,
96
+ // Goes up by one on every applied batch of operations.
97
+ rev: z.number().int().nonnegative(),
98
+ createdAt: z.string(),
99
+ meta: PlanMetaSchema,
100
+ items: z.array(PlanItemSchema)
101
+ });
102
+ export type Plan = z.infer<typeof PlanSchema>;
103
+
104
+ /*
105
+ * Operations name items by id and apply to the latest rev, without a base rev: a person's click and
106
+ * an agent's update never conflict, and an operation whose id is gone is refused instead.
107
+ */
108
+ export const PlanSetOpSchema = z.object({
109
+ op: z.literal('set'),
110
+ ids: z.array(PlanItemIdSchema).min(1),
111
+ state: PlanStepStateSchema,
112
+ note: NoteSchema.optional(),
113
+ // Agent only: the step that becomes active in the same rev, so the work moves on without a gap.
114
+ next: PlanItemIdSchema.optional()
115
+ });
116
+ export type PlanSetOp = z.infer<typeof PlanSetOpSchema>;
117
+
118
+ // An empty text clears the note.
119
+ export const PlanNoteOpSchema = z.object({
120
+ op: z.literal('note'),
121
+ id: PlanItemIdSchema,
122
+ text: NoteSchema
123
+ });
124
+ export type PlanNoteOp = z.infer<typeof PlanNoteOpSchema>;
125
+
126
+ export const PlanUnlockOpSchema = z.object({
127
+ op: z.literal('unlock'),
128
+ ids: z.union([z.array(PlanItemIdSchema).min(1), z.literal('all')])
129
+ });
130
+ export type PlanUnlockOp = z.infer<typeof PlanUnlockOpSchema>;
131
+
132
+ /* Without `under` and `after` an item goes last at the top; with only `after` it goes right after that item, beside it. */
133
+ export const PlanAddOpSchema = z.object({
134
+ op: z.literal('add'),
135
+ type: z.enum(['section', 'text', 'step']),
136
+ // Minted by the host when absent.
137
+ id: PlanItemIdSchema.optional(),
138
+ title: TitleSchema,
139
+ description: DescriptionSchema.optional(),
140
+ checks: PlanChecksSchema.optional(),
141
+ under: PlanItemIdSchema.optional(),
142
+ after: PlanItemIdSchema.optional()
143
+ });
144
+ export type PlanAddOp = z.infer<typeof PlanAddOpSchema>;
145
+
146
+ // An empty description removes it.
147
+ export const PlanEditOpSchema = z.object({
148
+ op: z.literal('edit'),
149
+ id: PlanItemIdSchema,
150
+ title: TitleSchema.optional(),
151
+ description: DescriptionSchema.optional(),
152
+ checks: PlanChecksSchema.optional()
153
+ });
154
+ export type PlanEditOp = z.infer<typeof PlanEditOpSchema>;
155
+
156
+ export const PlanMoveOpSchema = z.object({
157
+ op: z.literal('move'),
158
+ id: PlanItemIdSchema,
159
+ under: PlanItemIdSchema.optional(),
160
+ after: PlanItemIdSchema.optional()
161
+ });
162
+ export type PlanMoveOp = z.infer<typeof PlanMoveOpSchema>;
163
+
164
+ export const PlanRemoveOpSchema = z.object({
165
+ op: z.literal('remove'),
166
+ id: PlanItemIdSchema
167
+ });
168
+ export type PlanRemoveOp = z.infer<typeof PlanRemoveOpSchema>;
169
+
170
+ // An empty summary or status removes it.
171
+ export const PlanMetaOpSchema = z.object({
172
+ op: z.literal('meta'),
173
+ title: TitleSchema.optional(),
174
+ summary: DescriptionSchema.optional(),
175
+ status: z.string().max(PLAN_LIMITS.status).optional(),
176
+ checks: PlanChecksSchema.optional()
177
+ });
178
+ export type PlanMetaOp = z.infer<typeof PlanMetaOpSchema>;
179
+
180
+ // What a person may send: checking off, a note and lifting a lock. The structure is the agent's.
181
+ export const PlanPersonOpSchema = z.discriminatedUnion('op', [PlanSetOpSchema, PlanNoteOpSchema, PlanUnlockOpSchema]);
182
+ export type PlanPersonOp = z.infer<typeof PlanPersonOpSchema>;
183
+
184
+ export const PlanOpSchema = z.discriminatedUnion('op', [
185
+ PlanSetOpSchema,
186
+ PlanNoteOpSchema,
187
+ PlanUnlockOpSchema,
188
+ PlanAddOpSchema,
189
+ PlanEditOpSchema,
190
+ PlanMoveOpSchema,
191
+ PlanRemoveOpSchema,
192
+ PlanMetaOpSchema
193
+ ]);
194
+ export type PlanOp = z.infer<typeof PlanOpSchema>;