@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.
package/dist/apply.js ADDED
@@ -0,0 +1,379 @@
1
+ import { PlanChecksSchema, PlanItemIdSchema, PlanKindSchema, PlanMetaSchema, PlanOpSchema, PlanSchema, PlanSectionSchema, PlanStepSchema, PlanStepStateSchema, PlanTextSchema } from './protocol.js';
2
+ import { z } from 'zod';
3
+ import { canApply } from './permissions.js';
4
+ import { allItems, allSteps, effectiveChecks, locateItem, refuse, structureProblem } from './tree.js';
5
+ const ID_ALPHABET = 'abcdefghijklmnopqrstuvwxyz0123456789';
6
+ export function randomItemId() {
7
+ const bytes = crypto.getRandomValues(new Uint8Array(6));
8
+ return Array.from(bytes, (byte) => ID_ALPHABET[byte % ID_ALPHABET.length]).join('');
9
+ }
10
+ function issueMessage(error) {
11
+ const issue = error.issues[0];
12
+ if (!issue) {
13
+ return 'The plan is not valid';
14
+ }
15
+ const path = issue.path.map((part) => (typeof part === 'number' ? `[${part}]` : `.${String(part)}`)).join('');
16
+ return `${path ? `${path.replace(/^\./, '')}: ` : ''}${issue.message}`;
17
+ }
18
+ function idMinter(taken, mintId, minted) {
19
+ return () => {
20
+ for (let attempt = 0; attempt < 1000; attempt++) {
21
+ const id = mintId();
22
+ if (!taken.has(id)) {
23
+ taken.add(id);
24
+ minted.push(id);
25
+ return id;
26
+ }
27
+ }
28
+ throw new Error('Could not mint a free plan item id');
29
+ };
30
+ }
31
+ function setState(step, state, actor, now) {
32
+ // An agent confirming what a person set leaves the person's mark on it.
33
+ if (actor === 'agent' && step.by === 'person' && step.state === state) {
34
+ return;
35
+ }
36
+ // A person taking a mark away leaves nothing an agent has to respect, so a mis-click never locks the agent out.
37
+ if (actor === 'person' && state === 'open') {
38
+ step.state = state;
39
+ delete step.by;
40
+ delete step.at;
41
+ return;
42
+ }
43
+ step.state = state;
44
+ step.by = actor;
45
+ step.at = now;
46
+ }
47
+ function setNote(step, text) {
48
+ if (text === '') {
49
+ delete step.note;
50
+ }
51
+ else {
52
+ step.note = text;
53
+ }
54
+ }
55
+ /* The array a new or moved item goes into and where, or a refusal. */
56
+ function placeFor(plan, type, under, after) {
57
+ let parent = null;
58
+ let siblings = plan.items;
59
+ if (under !== undefined) {
60
+ const location = locateItem(plan, under);
61
+ if (!location) {
62
+ return refuse('plan-missing-item', `The plan has no item "${under}"`);
63
+ }
64
+ const target = location.item;
65
+ if (target.type === 'text') {
66
+ return refuse('plan-bad-position', `"${under}" is a text block, which holds no items`);
67
+ }
68
+ parent = target;
69
+ if (target.type === 'section') {
70
+ siblings = target.items;
71
+ }
72
+ else {
73
+ target.steps ??= [];
74
+ siblings = target.steps;
75
+ }
76
+ }
77
+ else if (after !== undefined) {
78
+ const location = locateItem(plan, after);
79
+ if (!location) {
80
+ return refuse('plan-missing-item', `The plan has no item "${after}"`);
81
+ }
82
+ parent = location.parent;
83
+ siblings = location.siblings;
84
+ }
85
+ if (type === 'section' && parent !== null) {
86
+ return refuse('plan-bad-position', 'A section only stands at the top of a plan');
87
+ }
88
+ if (type === 'text' && parent?.type === 'step') {
89
+ return refuse('plan-bad-position', 'A step only holds steps, not a text block');
90
+ }
91
+ if (after === undefined) {
92
+ return { siblings, index: siblings.length, parent };
93
+ }
94
+ const index = siblings.findIndex((item) => item.id === after);
95
+ if (index === -1) {
96
+ return refuse('plan-bad-position', `"${after}" is not directly under "${under}"`);
97
+ }
98
+ return { siblings, index: index + 1, parent };
99
+ }
100
+ /* A leaf that gets its first sub-step: its state now follows from its children. */
101
+ function becomeParent(parent, dropped) {
102
+ if (parent?.type !== 'step' || (parent.steps?.length ?? 0) !== 1) {
103
+ return;
104
+ }
105
+ if (parent.state !== undefined) {
106
+ dropped.push(parent.id);
107
+ }
108
+ delete parent.state;
109
+ delete parent.by;
110
+ delete parent.at;
111
+ }
112
+ function pruneEmptySteps(items) {
113
+ for (const item of allItems(items)) {
114
+ if (item.type === 'step' && item.steps?.length === 0) {
115
+ delete item.steps;
116
+ }
117
+ }
118
+ }
119
+ function applyOne(plan, op, options, mint, dropped) {
120
+ const verdict = canApply(op, options.actor, plan);
121
+ if (!verdict.ok) {
122
+ return verdict;
123
+ }
124
+ const stepAt = (id) => locateItem(plan, id).item;
125
+ switch (op.op) {
126
+ case 'set': {
127
+ for (const id of op.ids) {
128
+ const step = stepAt(id);
129
+ setState(step, op.state, options.actor, options.now);
130
+ if (op.note !== undefined) {
131
+ setNote(step, op.note);
132
+ }
133
+ }
134
+ if (op.next !== undefined) {
135
+ setState(stepAt(op.next), 'active', options.actor, options.now);
136
+ }
137
+ return null;
138
+ }
139
+ case 'note':
140
+ setNote(stepAt(op.id), op.text);
141
+ return null;
142
+ case 'unlock': {
143
+ const roots = op.ids === 'all' ? plan.items : op.ids.map((id) => stepAt(id));
144
+ for (const step of allSteps(roots)) {
145
+ if (effectiveChecks(plan, step) === 'agent') {
146
+ step.unlocked = true;
147
+ }
148
+ }
149
+ return null;
150
+ }
151
+ case 'add': {
152
+ const id = op.id ?? mint();
153
+ if (op.id !== undefined && locateItem(plan, op.id)) {
154
+ return refuse('duplicate-id', `The plan already has an item "${op.id}"`);
155
+ }
156
+ if (op.checks !== undefined && op.type !== 'step') {
157
+ return refuse('plan-invalid', 'Only a step has checks');
158
+ }
159
+ const place = placeFor(plan, op.type, op.under, op.after);
160
+ if ('ok' in place) {
161
+ return place;
162
+ }
163
+ const base = { id, title: op.title, ...(op.description ? { description: op.description } : {}) };
164
+ const item = op.type === 'section'
165
+ ? { type: 'section', ...base, items: [] }
166
+ : op.type === 'text'
167
+ ? { type: 'text', ...base }
168
+ : { type: 'step', ...base, ...(op.checks ? { checks: op.checks } : {}) };
169
+ place.siblings.splice(place.index, 0, item);
170
+ becomeParent(place.parent, dropped);
171
+ return null;
172
+ }
173
+ case 'edit': {
174
+ const item = locateItem(plan, op.id).item;
175
+ if (op.checks !== undefined && item.type !== 'step') {
176
+ return refuse('plan-invalid', 'Only a step has checks');
177
+ }
178
+ if (op.title !== undefined) {
179
+ item.title = op.title;
180
+ }
181
+ if (op.description === '') {
182
+ delete item.description;
183
+ }
184
+ else if (op.description !== undefined) {
185
+ item.description = op.description;
186
+ }
187
+ if (op.checks !== undefined && item.type === 'step') {
188
+ item.checks = op.checks;
189
+ }
190
+ return null;
191
+ }
192
+ case 'move': {
193
+ const location = locateItem(plan, op.id);
194
+ if (op.after === op.id || (op.under !== undefined && allItems([location.item]).some((item) => item.id === op.under))) {
195
+ return refuse('plan-bad-position', `"${op.id}" cannot move into itself`);
196
+ }
197
+ location.siblings.splice(location.index, 1);
198
+ const place = placeFor(plan, location.item.type, op.under, op.after);
199
+ if ('ok' in place) {
200
+ return place;
201
+ }
202
+ place.siblings.splice(place.index, 0, location.item);
203
+ becomeParent(place.parent, dropped);
204
+ return null;
205
+ }
206
+ case 'remove': {
207
+ const location = locateItem(plan, op.id);
208
+ location.siblings.splice(location.index, 1);
209
+ return null;
210
+ }
211
+ case 'meta': {
212
+ if (op.title !== undefined) {
213
+ plan.meta.title = op.title;
214
+ }
215
+ for (const key of ['summary', 'status']) {
216
+ if (op[key] === '') {
217
+ delete plan.meta[key];
218
+ }
219
+ else if (op[key] !== undefined) {
220
+ plan.meta[key] = op[key];
221
+ }
222
+ }
223
+ if (op.checks !== undefined) {
224
+ plan.meta.checks = op.checks;
225
+ }
226
+ return null;
227
+ }
228
+ }
229
+ }
230
+ /*
231
+ * Applies a batch of operations for one actor, all or nothing. Each operation sees the plan as the
232
+ * ones before it left it, and the whole batch is one rev, so a step done and the next one active land
233
+ * together.
234
+ */
235
+ export function applyPlanOps(plan, ops, options) {
236
+ const parsedOps = z.array(PlanOpSchema).min(1).safeParse(ops);
237
+ if (!parsedOps.success) {
238
+ return refuse('plan-invalid', issueMessage(parsedOps.error));
239
+ }
240
+ const next = structuredClone(plan);
241
+ const minted = [];
242
+ const dropped = [];
243
+ const taken = new Set(allItems(next.items).map((item) => item.id));
244
+ const mint = idMinter(taken, options.mintId ?? randomItemId, minted);
245
+ for (const op of parsedOps.data) {
246
+ const problem = applyOne(next, op, options, mint, dropped);
247
+ if (problem) {
248
+ return problem;
249
+ }
250
+ for (const item of allItems(next.items)) {
251
+ taken.add(item.id);
252
+ }
253
+ }
254
+ pruneEmptySteps(next.items);
255
+ next.rev = plan.rev + 1;
256
+ const checked = validatePlan(next);
257
+ if (!checked.ok) {
258
+ return checked;
259
+ }
260
+ return { ok: true, plan: checked.plan, minted, dropped };
261
+ }
262
+ /* A plan as it is stored or sent: the schema, then the rules that need the whole tree. */
263
+ export function validatePlan(value) {
264
+ const parsed = PlanSchema.safeParse(value);
265
+ if (!parsed.success) {
266
+ return refuse('plan-invalid', issueMessage(parsed.error));
267
+ }
268
+ const problem = structureProblem(parsed.data);
269
+ return problem ?? { ok: true, plan: parsed.data };
270
+ }
271
+ /*
272
+ * What an agent hands `plan new`. Strict, so a misspelled field is refused by its path instead of
273
+ * dropped. Ids are optional and minted when absent; who set a state and when is never the agent's to say.
274
+ */
275
+ export const PlanDraftStepSchema = z.strictObject({
276
+ type: PlanStepSchema.shape.type,
277
+ id: PlanItemIdSchema.optional(),
278
+ title: PlanStepSchema.shape.title,
279
+ description: PlanStepSchema.shape.description,
280
+ checks: PlanStepSchema.shape.checks,
281
+ state: PlanStepStateSchema.optional(),
282
+ note: PlanStepSchema.shape.note,
283
+ get steps() {
284
+ return z.array(PlanDraftStepSchema).optional();
285
+ }
286
+ });
287
+ export const PlanDraftTextSchema = z.strictObject({ ...PlanTextSchema.shape, id: PlanItemIdSchema.optional() });
288
+ export const PlanDraftSectionSchema = z.strictObject({
289
+ ...PlanSectionSchema.shape,
290
+ id: PlanItemIdSchema.optional(),
291
+ items: z.array(z.discriminatedUnion('type', [PlanDraftTextSchema, PlanDraftStepSchema]))
292
+ });
293
+ export const PlanDraftItemSchema = z.discriminatedUnion('type', [PlanDraftSectionSchema, PlanDraftTextSchema, PlanDraftStepSchema]);
294
+ export const PlanDraftMetaSchema = z.strictObject({
295
+ title: PlanMetaSchema.shape.title.optional(),
296
+ kind: PlanKindSchema.optional(),
297
+ summary: PlanMetaSchema.shape.summary,
298
+ status: PlanMetaSchema.shape.status,
299
+ checks: PlanChecksSchema.optional()
300
+ });
301
+ export const PlanDraftSchema = z.strictObject({
302
+ meta: PlanDraftMetaSchema.optional(),
303
+ items: z.array(PlanDraftItemSchema)
304
+ });
305
+ export function parsePlanDraft(value) {
306
+ const parsed = PlanDraftSchema.safeParse(value);
307
+ return parsed.success ? { ok: true, draft: parsed.data } : refuse('plan-invalid', issueMessage(parsed.error));
308
+ }
309
+ /* A new plan from an agent's draft: ids minted, states marked as the agent's, and the same rules as any later change. */
310
+ export function createPlan(draft, options) {
311
+ const parsed = PlanDraftSchema.safeParse(draft);
312
+ if (!parsed.success) {
313
+ return refuse('plan-invalid', issueMessage(parsed.error));
314
+ }
315
+ const source = parsed.data;
316
+ const title = options.meta?.title ?? source.meta?.title;
317
+ if (!title) {
318
+ return refuse('plan-invalid', 'A plan needs a title');
319
+ }
320
+ const meta = {
321
+ title,
322
+ kind: options.meta?.kind ?? source.meta?.kind ?? 'steps',
323
+ ...(source.meta?.summary ? { summary: source.meta.summary } : {}),
324
+ ...(source.meta?.status ? { status: source.meta.status } : {}),
325
+ checks: options.meta?.checks ?? source.meta?.checks ?? 'anyone'
326
+ };
327
+ const minted = [];
328
+ const taken = new Set();
329
+ for (const item of allDraftItems(source.items)) {
330
+ if (item.id !== undefined) {
331
+ if (taken.has(item.id)) {
332
+ return refuse('duplicate-id', `Two items share the id "${item.id}"`);
333
+ }
334
+ taken.add(item.id);
335
+ }
336
+ }
337
+ const mint = idMinter(taken, options.mintId ?? randomItemId, minted);
338
+ const described = (value) => {
339
+ if (value.description === '') {
340
+ delete value.description;
341
+ }
342
+ return value;
343
+ };
344
+ const toStep = (step) => {
345
+ const { steps, state, note, ...rest } = step;
346
+ const result = described({ ...rest, id: step.id ?? mint() });
347
+ if (steps && steps.length > 0) {
348
+ result.steps = steps.map(toStep);
349
+ }
350
+ if (state !== undefined) {
351
+ Object.assign(result, { state, by: 'agent', at: options.now });
352
+ }
353
+ if (note) {
354
+ result.note = note;
355
+ }
356
+ return result;
357
+ };
358
+ const toText = (text) => described({ ...text, id: text.id ?? mint() });
359
+ const items = source.items.map((item) => {
360
+ if (item.type === 'section') {
361
+ return described({ ...item, id: item.id ?? mint(), items: item.items.map((child) => (child.type === 'text' ? toText(child) : toStep(child))) });
362
+ }
363
+ return item.type === 'text' ? toText(item) : toStep(item);
364
+ });
365
+ const plan = { id: options.id, rev: 0, createdAt: options.now, meta, items };
366
+ const checked = validatePlan(plan);
367
+ if (!checked.ok) {
368
+ return checked;
369
+ }
370
+ for (const step of allSteps(checked.plan.items)) {
371
+ if (step.state !== undefined && effectiveChecks(checked.plan, step) === 'person') {
372
+ return refuse('person-only', `Only a person checks "${step.id}"`);
373
+ }
374
+ }
375
+ return { ok: true, plan: checked.plan, minted, dropped: [] };
376
+ }
377
+ function allDraftItems(items) {
378
+ return items.flatMap((item) => [item, ...allDraftItems(item.type === 'section' ? item.items : item.type === 'step' ? (item.steps ?? []) : [])]);
379
+ }
@@ -0,0 +1,5 @@
1
+ export { type PlanApplyOptions, type PlanApplied, randomItemId, applyPlanOps, validatePlan, PlanDraftStepSchema, type PlanDraftStep, PlanDraftTextSchema, type PlanDraftText, PlanDraftSectionSchema, type PlanDraftSection, PlanDraftItemSchema, type PlanDraftItem, PlanDraftMetaSchema, type PlanDraftMeta, PlanDraftSchema, type PlanDraft, parsePlanDraft, type PlanCreateOptions, createPlan } from './apply.ts';
2
+ export { planToMarkdown, parsePlanMarkdown } from './markdown.ts';
3
+ export { type PlanVerdict, canApply, canDeletePlan } from './permissions.ts';
4
+ export { PLAN_STATE_MARKERS, PLAN_LEGEND, type PlanTextOptions, progressText, renderPlanText } from './text.ts';
5
+ export { type PlanRefusalCode, type PlanRefusal, refuse, type PlanLocation, locateItem, findItem, allItems, allSteps, isParentStep, effectiveChecks, isFinishedOutcome, deriveState, stepState, type PlanProgress, planProgress, leafSteps, activeStepIds, holdsPersonState, structureProblem } from './tree.ts';
package/dist/index.js ADDED
@@ -0,0 +1,5 @@
1
+ export { randomItemId, applyPlanOps, validatePlan, PlanDraftStepSchema, PlanDraftTextSchema, PlanDraftSectionSchema, PlanDraftItemSchema, PlanDraftMetaSchema, PlanDraftSchema, parsePlanDraft, createPlan } from './apply.js';
2
+ export { planToMarkdown, parsePlanMarkdown } from './markdown.js';
3
+ export { canApply, canDeletePlan } from './permissions.js';
4
+ export { PLAN_STATE_MARKERS, PLAN_LEGEND, progressText, renderPlanText } from './text.js';
5
+ export { refuse, locateItem, findItem, allItems, allSteps, isParentStep, effectiveChecks, isFinishedOutcome, deriveState, stepState, planProgress, leafSteps, activeStepIds, holdsPersonState, structureProblem } from './tree.js';
@@ -0,0 +1,8 @@
1
+ import type { Plan } from './protocol.ts';
2
+ import type { PlanDraft } from './apply.ts';
3
+ import { type PlanRefusal } from './tree.ts';
4
+ export declare function planToMarkdown(plan: Plan): string;
5
+ export declare function parsePlanMarkdown(markdown: string): {
6
+ ok: true;
7
+ draft: PlanDraft;
8
+ } | PlanRefusal;
@@ -0,0 +1,271 @@
1
+ import { PLAN_STATE_MARKERS } from './text.js';
2
+ import { refuse, stepState } from './tree.js';
3
+ const INDENT = ' ';
4
+ const MARKER_STATES = {
5
+ ' ': undefined,
6
+ x: 'done',
7
+ X: 'done',
8
+ '~': 'active',
9
+ '!': 'failed',
10
+ '-': 'skipped',
11
+ '?': 'blocked',
12
+ w: 'warning',
13
+ W: 'warning',
14
+ i: 'info',
15
+ I: 'info'
16
+ };
17
+ /* 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. */
18
+ const MARKUP = /^(#{1,6}\s|(-{3,}|\*{3,}|_{3,})$|[-*+]\s|>|\\)/;
19
+ function escapeLine(line) {
20
+ return MARKUP.test(line.trimStart()) ? `\\${line}` : line;
21
+ }
22
+ function escapeText(text) {
23
+ return text.split('\n').map(escapeLine).join('\n');
24
+ }
25
+ function escapeBold(title) {
26
+ return title.replace(/[\\*]/g, '\\$&');
27
+ }
28
+ /* A later line of a text block that starts in bold would start a text block of its own. */
29
+ function escapeQuoted(line) {
30
+ return /^(\*\*|\\)/.test(line.trimStart()) ? `\\${line}` : line;
31
+ }
32
+ function dropEscape(line) {
33
+ return line.trimStart().startsWith('\\') ? line.replace('\\', '') : line;
34
+ }
35
+ /*
36
+ * A plan as a GFM task list: `#` the title, `##` a section, `> **Title**` a text block, `- [ ]` a
37
+ * step with its sub-steps indented under it. Beyond `[ ]` and `[x]` a step carries the markers of
38
+ * `plan read`, and a `>` line under a step is its note. Ids, who set a state and the locks stay out.
39
+ * A line of text the reader would take for markup starts with a backslash, which the reader drops.
40
+ */
41
+ export function planToMarkdown(plan) {
42
+ const blocks = [`# ${plan.meta.title}`];
43
+ if (plan.meta.summary) {
44
+ blocks.push(escapeText(plan.meta.summary));
45
+ }
46
+ const stepLines = (step, depth) => {
47
+ const indent = INDENT.repeat(depth);
48
+ const inner = INDENT.repeat(depth + 1);
49
+ const lines = [`${indent}- ${PLAN_STATE_MARKERS[stepState(step)]} ${step.title}`];
50
+ for (const line of step.description?.split('\n') ?? []) {
51
+ lines.push(line === '' ? '' : `${inner}${escapeLine(line)}`);
52
+ }
53
+ for (const line of step.note?.split('\n') ?? []) {
54
+ lines.push(`${inner}> ${line}`);
55
+ }
56
+ for (const child of step.steps ?? []) {
57
+ lines.push(...stepLines(child, depth + 1));
58
+ }
59
+ return lines;
60
+ };
61
+ const textBlock = (item) => {
62
+ const [first = '', ...rest] = item.description?.split('\n') ?? [];
63
+ return [`> **${escapeBold(item.title)}**${first ? ` ${first}` : ''}`, ...rest.map((line) => `> ${escapeQuoted(line)}`)].join('\n');
64
+ };
65
+ const itemBlocks = (items) => {
66
+ const result = [];
67
+ let list = [];
68
+ const flush = () => {
69
+ if (list.length > 0) {
70
+ result.push(list.join('\n'));
71
+ list = [];
72
+ }
73
+ };
74
+ for (const item of items) {
75
+ if (item.type === 'step') {
76
+ list.push(...stepLines(item, 0));
77
+ }
78
+ else if (item.type === 'text') {
79
+ flush();
80
+ result.push(textBlock(item));
81
+ }
82
+ }
83
+ flush();
84
+ return result;
85
+ };
86
+ let inSection = false;
87
+ let loose = [];
88
+ const flushLoose = () => {
89
+ if (loose.length > 0) {
90
+ // Only a rule brings the items after a section back to the top of the plan.
91
+ if (inSection) {
92
+ blocks.push('---');
93
+ inSection = false;
94
+ }
95
+ blocks.push(...itemBlocks(loose));
96
+ loose = [];
97
+ }
98
+ };
99
+ for (const item of plan.items) {
100
+ if (item.type !== 'section') {
101
+ loose.push(item);
102
+ continue;
103
+ }
104
+ flushLoose();
105
+ blocks.push(`## ${item.title}`);
106
+ if (item.description) {
107
+ blocks.push(escapeText(item.description));
108
+ }
109
+ blocks.push(...itemBlocks(item.items));
110
+ inSection = true;
111
+ }
112
+ flushLoose();
113
+ return `${blocks.join('\n\n')}\n`;
114
+ }
115
+ function indentOf(line) {
116
+ let width = 0;
117
+ for (const char of line) {
118
+ if (char === ' ') {
119
+ width++;
120
+ }
121
+ else if (char === '\t') {
122
+ width += 4;
123
+ }
124
+ else {
125
+ break;
126
+ }
127
+ }
128
+ return width;
129
+ }
130
+ function appendLine(value, line, blanks = 0) {
131
+ return value === undefined ? line : `${value}\n${'\n'.repeat(blanks)}${line}`;
132
+ }
133
+ /*
134
+ * Reads a GFM task list into a draft for `plan new`. Lenient where Markdown is (a list item without a
135
+ * checkbox is an open step), strict where a line cannot belong to anything.
136
+ */
137
+ export function parsePlanMarkdown(markdown) {
138
+ const draft = { items: [] };
139
+ let section = null;
140
+ let stack = [];
141
+ let text = null;
142
+ // The text the last line of prose went on and the blank lines since, so a paragraph break inside it stays.
143
+ let continuing = null;
144
+ let blanks = 0;
145
+ const container = () => (section ? section.items : draft.items);
146
+ const popTo = (indent) => {
147
+ while (stack.length > 0 && stack[stack.length - 1].indent >= indent) {
148
+ stack.pop();
149
+ }
150
+ };
151
+ const lines = markdown.replace(/\r\n?/g, '\n').split('\n');
152
+ for (let index = 0; index < lines.length; index++) {
153
+ const raw = lines[index].trimEnd();
154
+ const content = raw.trim();
155
+ const indent = indentOf(raw);
156
+ const invalid = (message) => refuse('plan-invalid', `Line ${index + 1}: ${message}`);
157
+ if (content === '') {
158
+ text = null;
159
+ blanks++;
160
+ continue;
161
+ }
162
+ const previous = continuing;
163
+ const gap = blanks;
164
+ continuing = null;
165
+ blanks = 0;
166
+ const blanksBefore = (target) => {
167
+ continuing = target;
168
+ return previous === target ? gap : 0;
169
+ };
170
+ const plain = dropEscape(content);
171
+ const heading = /^(#{1,6})\s+(.*)$/.exec(content);
172
+ if (heading && indent < 4) {
173
+ text = null;
174
+ stack = [];
175
+ const title = heading[2].trim();
176
+ if (heading[1] === '#' && draft.meta?.title === undefined && draft.items.length === 0 && !section) {
177
+ draft.meta = { ...draft.meta, title };
178
+ }
179
+ else if (heading[1] === '##') {
180
+ section = { type: 'section', title, items: [] };
181
+ draft.items.push(section);
182
+ }
183
+ else {
184
+ return invalid('only one "#" title before everything else and "##" sections are headings in a plan');
185
+ }
186
+ continue;
187
+ }
188
+ if (/^(-{3,}|\*{3,}|_{3,})$/.test(content) && indent < 4) {
189
+ section = null;
190
+ stack = [];
191
+ text = null;
192
+ continue;
193
+ }
194
+ const item = /^[-*+]\s+(?:\[(.)\]\s+)?(.*)$/.exec(content);
195
+ if (item) {
196
+ text = null;
197
+ const marker = item[1];
198
+ if (marker !== undefined && !(marker in MARKER_STATES)) {
199
+ return invalid(`"[${marker}]" is not a step marker`);
200
+ }
201
+ const title = item[2].trim();
202
+ if (title === '') {
203
+ return invalid('a step needs a title');
204
+ }
205
+ const state = marker === undefined ? undefined : MARKER_STATES[marker];
206
+ const step = { type: 'step', title, ...(state ? { state } : {}) };
207
+ popTo(indent);
208
+ const parent = stack[stack.length - 1]?.step;
209
+ if (parent) {
210
+ parent.steps ??= [];
211
+ parent.steps.push(step);
212
+ }
213
+ else {
214
+ container().push(step);
215
+ }
216
+ stack.push({ indent, step });
217
+ continue;
218
+ }
219
+ const quote = /^>\s?(.*)$/.exec(content);
220
+ popTo(indent);
221
+ const owner = stack[stack.length - 1]?.step;
222
+ if (owner && indent > 0) {
223
+ if (quote) {
224
+ owner.note = appendLine(owner.note, quote[1]);
225
+ }
226
+ else {
227
+ owner.description = appendLine(owner.description, plain, blanksBefore(owner));
228
+ }
229
+ continue;
230
+ }
231
+ stack = [];
232
+ if (quote) {
233
+ const titled = /^\*\*((?:\\.|[^\\])+?)\*\*\s*(.*)$/.exec(quote[1].trim());
234
+ if (titled) {
235
+ const title = titled[1].trim().replace(/\\([\\*])/g, '$1');
236
+ text = { type: 'text', title, ...(titled[2] ? { description: titled[2] } : {}) };
237
+ container().push(text);
238
+ }
239
+ else if (text) {
240
+ text.description = appendLine(text.description, dropEscape(quote[1]));
241
+ }
242
+ else {
243
+ return invalid('a text block starts with a bold title: > **Title** description');
244
+ }
245
+ continue;
246
+ }
247
+ if (!section && draft.items.length === 0) {
248
+ draft.meta = { ...draft.meta, summary: appendLine(draft.meta?.summary, plain, blanksBefore(draft)) };
249
+ continue;
250
+ }
251
+ if (section && section.items.length === 0) {
252
+ section.description = appendLine(section.description, plain, blanksBefore(section));
253
+ continue;
254
+ }
255
+ return invalid('this line is not a step, a section, a text block or a description');
256
+ }
257
+ // A parent's state follows from its children, so a marker on it says nothing.
258
+ const clearParents = (items) => {
259
+ for (const entry of items) {
260
+ if (entry.type === 'section') {
261
+ clearParents(entry.items);
262
+ }
263
+ else if (entry.type === 'step' && entry.steps) {
264
+ delete entry.state;
265
+ clearParents(entry.steps);
266
+ }
267
+ }
268
+ };
269
+ clearParents(draft.items);
270
+ return { ok: true, draft };
271
+ }
@@ -0,0 +1,7 @@
1
+ import type { Plan, PlanActor, PlanOp } from './protocol.ts';
2
+ import { type PlanRefusal } from './tree.ts';
3
+ export type PlanVerdict = {
4
+ ok: true;
5
+ } | PlanRefusal;
6
+ export declare function canApply(op: PlanOp, actor: PlanActor, plan: Plan): PlanVerdict;
7
+ export declare function canDeletePlan(plan: Plan, actor: PlanActor): PlanVerdict;