@adecore/plan 0.39.0 → 0.40.1

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 CHANGED
@@ -44,14 +44,13 @@ function setState(step, state, actor, now) {
44
44
  if (actor === 'agent' && step.by === 'person' && step.state === state) {
45
45
  return;
46
46
  }
47
+ step.state = state;
47
48
  // A person taking a mark away leaves nothing an agent has to respect, so a mis-click never locks the agent out.
48
49
  if (actor === 'person' && state === 'open') {
49
- step.state = state;
50
50
  delete step.by;
51
51
  delete step.at;
52
52
  return;
53
53
  }
54
- step.state = state;
55
54
  step.by = actor;
56
55
  step.at = now;
57
56
  }
@@ -161,7 +160,7 @@ function applyOne(plan, op, options, mint, dropped) {
161
160
  setNote(stepAt(op.id), op.text);
162
161
  return null;
163
162
  case 'unlock': {
164
- const roots = op.ids === 'all' ? plan.items : op.ids.map((id) => stepAt(id));
163
+ const roots = op.ids === 'all' ? plan.items : op.ids.map(stepAt);
165
164
  for (const step of allSteps(roots)) {
166
165
  if (effectiveChecks(plan, step) === 'agent') {
167
166
  step.unlocked = true;
package/dist/markdown.js CHANGED
@@ -144,7 +144,7 @@ export function parsePlanMarkdown(markdown) {
144
144
  let blanks = 0;
145
145
  const container = () => (section ? section.items : draft.items);
146
146
  const popTo = (indent) => {
147
- while (stack.length > 0 && stack[stack.length - 1].indent >= indent) {
147
+ while ((stack.at(-1)?.indent ?? -1) >= indent) {
148
148
  stack.pop();
149
149
  }
150
150
  };
@@ -205,7 +205,7 @@ export function parsePlanMarkdown(markdown) {
205
205
  const state = marker === undefined ? undefined : MARKER_STATES[marker];
206
206
  const step = { type: 'step', title, ...(state ? { state } : {}) };
207
207
  popTo(indent);
208
- const parent = stack[stack.length - 1]?.step;
208
+ const parent = stack.at(-1)?.step;
209
209
  if (parent) {
210
210
  parent.steps ??= [];
211
211
  parent.steps.push(step);
@@ -218,7 +218,7 @@ export function parsePlanMarkdown(markdown) {
218
218
  }
219
219
  const quote = /^>\s?(.*)$/.exec(content);
220
220
  popTo(indent);
221
- const owner = stack[stack.length - 1]?.step;
221
+ const owner = stack.at(-1)?.step;
222
222
  if (owner && indent > 0) {
223
223
  if (quote) {
224
224
  owner.note = appendLine(owner.note, quote[1]);
package/dist/text.js CHANGED
@@ -29,16 +29,14 @@ function counter(progress) {
29
29
  export function progressText(plan) {
30
30
  const progress = planProgress(plan.items);
31
31
  const extra = (count, label) => (count > 0 ? [`${count} ${label}`] : []);
32
- const warnings = progress.warning === 1 ? 'warning' : 'warnings';
32
+ const unfinished = [...extra(progress.failed, 'failed'), ...extra(progress.skipped, 'skipped'), ...extra(progress.blocked, 'blocked')];
33
33
  if (plan.meta.kind === 'test') {
34
34
  return [
35
35
  `${progress.finished} of ${progress.total} run`,
36
36
  `${progress.done} passed`,
37
- ...extra(progress.warning, warnings),
37
+ ...extra(progress.warning, progress.warning === 1 ? 'warning' : 'warnings'),
38
38
  ...extra(progress.info, 'info'),
39
- ...extra(progress.failed, 'failed'),
40
- ...extra(progress.skipped, 'skipped'),
41
- ...extra(progress.blocked, 'blocked')
39
+ ...unfinished
42
40
  ].join(', ');
43
41
  }
44
42
  // In a steps plan a warning or info is still done; the extras say which of the done steps to read.
@@ -46,9 +44,7 @@ export function progressText(plan) {
46
44
  `${progress.done + progress.warning + progress.info} of ${progress.total} done`,
47
45
  ...extra(progress.warning, `with ${progress.warning === 1 ? 'a warning' : 'warnings'}`),
48
46
  ...extra(progress.info, 'with info'),
49
- ...extra(progress.failed, 'failed'),
50
- ...extra(progress.skipped, 'skipped'),
51
- ...extra(progress.blocked, 'blocked')
47
+ ...unfinished
52
48
  ].join(', ');
53
49
  }
54
50
  function sentences(parts) {
@@ -73,7 +69,7 @@ export function renderPlanText(plan, options = {}) {
73
69
  if (active.length > 0) {
74
70
  second.push(`Now: ${active.map((step) => `"${step.title}" [${step.id}]`).join(', ')}`);
75
71
  }
76
- if (options.others && options.others.length > 0) {
72
+ if (options.others?.length) {
77
73
  const others = options.others.map((other) => `${other.id} "${other.meta.title}" (${other.meta.kind}, ${counter(planProgress(other.items))})`);
78
74
  second.push(`Also in this chat: ${others.join(', ')}`);
79
75
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adecore/plan",
3
- "version": "0.39.0",
3
+ "version": "0.40.1",
4
4
  "description": "Plan schemas, atomic tree operations, actor permissions and Markdown.",
5
5
  "license": "FSL-1.1-MIT",
6
6
  "author": "Bas Milius",
@@ -40,11 +40,11 @@
40
40
  "test": "bun test --conditions=source"
41
41
  },
42
42
  "devDependencies": {
43
- "@types/bun": "^1.4.2",
44
- "@types/node": "^24.13.6",
43
+ "@types/bun": "^1.4.4",
44
+ "@types/node": "^24.19.2",
45
45
  "typescript": "^7.0.2"
46
46
  },
47
47
  "dependencies": {
48
- "zod": "^4.6.5"
48
+ "zod": "^4.6.7"
49
49
  }
50
50
  }
package/src/apply.ts CHANGED
@@ -47,6 +47,7 @@ const ID_BYTE_LIMIT = 256 - (256 % ID_ALPHABET.length);
47
47
 
48
48
  export function randomItemId(): string {
49
49
  let id = '';
50
+
50
51
  while (id.length < ID_LENGTH) {
51
52
  for (const byte of crypto.getRandomValues(new Uint8Array(ID_LENGTH))) {
52
53
  if (byte < ID_BYTE_LIMIT && id.length < ID_LENGTH) {
@@ -54,15 +55,19 @@ export function randomItemId(): string {
54
55
  }
55
56
  }
56
57
  }
58
+
57
59
  return id;
58
60
  }
59
61
 
60
62
  function issueMessage(error: z.ZodError): string {
61
63
  const issue = error.issues[0];
64
+
62
65
  if (!issue) {
63
66
  return 'The plan is not valid';
64
67
  }
68
+
65
69
  const path = issue.path.map((part) => (typeof part === 'number' ? `[${part}]` : `.${String(part)}`)).join('');
70
+
66
71
  return `${path ? `${path.replace(/^\./, '')}: ` : ''}${issue.message}`;
67
72
  }
68
73
 
@@ -72,12 +77,15 @@ function idMinter(taken: Set<string>, mintId: () => string, minted: string[]) {
72
77
  return (): string | PlanRefusal => {
73
78
  for (let attempt = 0; attempt < MINT_ATTEMPTS; attempt++) {
74
79
  const id = mintId();
80
+
75
81
  if (!taken.has(id)) {
76
82
  taken.add(id);
77
83
  minted.push(id);
84
+
78
85
  return id;
79
86
  }
80
87
  }
88
+
81
89
  return refuse('duplicate-id', `mintId gave no id the plan does not already have in ${MINT_ATTEMPTS} tries`);
82
90
  };
83
91
  }
@@ -87,14 +95,17 @@ function setState(step: PlanStep, state: PlanStepState, actor: PlanActor, now: s
87
95
  if (actor === 'agent' && step.by === 'person' && step.state === state) {
88
96
  return;
89
97
  }
98
+
99
+ step.state = state;
100
+
90
101
  // A person taking a mark away leaves nothing an agent has to respect, so a mis-click never locks the agent out.
91
102
  if (actor === 'person' && state === 'open') {
92
- step.state = state;
93
103
  delete step.by;
94
104
  delete step.at;
105
+
95
106
  return;
96
107
  }
97
- step.state = state;
108
+
98
109
  step.by = actor;
99
110
  step.at = now;
100
111
  }
@@ -116,16 +127,22 @@ function placeFor(
116
127
  ): { siblings: PlanItem[]; index: number; parent: PlanSection | PlanStep | null } | PlanRefusal {
117
128
  let parent: PlanSection | PlanStep | null = null;
118
129
  let siblings: PlanItem[] = plan.items;
130
+
119
131
  if (under !== undefined) {
120
132
  const location = locateItem(plan, under);
133
+
121
134
  if (!location) {
122
135
  return missingItem(under);
123
136
  }
137
+
124
138
  const target = location.item;
139
+
125
140
  if (target.type === 'text') {
126
141
  return refuse('plan-bad-position', `"${under}" is a text block, which holds no items`);
127
142
  }
143
+
128
144
  parent = target;
145
+
129
146
  if (target.type === 'section') {
130
147
  siblings = target.items;
131
148
  } else {
@@ -134,25 +151,33 @@ function placeFor(
134
151
  }
135
152
  } else if (after !== undefined) {
136
153
  const location = locateItem(plan, after);
154
+
137
155
  if (!location) {
138
156
  return missingItem(after);
139
157
  }
158
+
140
159
  parent = location.parent;
141
160
  siblings = location.siblings;
142
161
  }
162
+
143
163
  if (type === 'section' && parent !== null) {
144
164
  return refuse('plan-bad-position', 'A section only stands at the top of a plan');
145
165
  }
166
+
146
167
  if (type === 'text' && parent?.type === 'step') {
147
168
  return refuse('plan-bad-position', 'A step only holds steps, not a text block');
148
169
  }
170
+
149
171
  if (after === undefined) {
150
172
  return { siblings, index: siblings.length, parent };
151
173
  }
174
+
152
175
  const index = siblings.findIndex((item) => item.id === after);
176
+
153
177
  if (index === -1) {
154
178
  return refuse('plan-bad-position', `"${after}" is not directly under "${under}"`);
155
179
  }
180
+
156
181
  return { siblings, index: index + 1, parent };
157
182
  }
158
183
 
@@ -161,9 +186,11 @@ function becomeParent(parent: PlanSection | PlanStep | null, dropped: string[]):
161
186
  if (parent?.type !== 'step' || (parent.steps?.length ?? 0) !== 1) {
162
187
  return;
163
188
  }
189
+
164
190
  if (parent.state !== undefined) {
165
191
  dropped.push(parent.id);
166
192
  }
193
+
167
194
  delete parent.state;
168
195
  delete parent.by;
169
196
  delete parent.at;
@@ -179,107 +206,138 @@ function pruneEmptySteps(items: readonly PlanItem[]): void {
179
206
 
180
207
  function newItem(op: Extract<PlanOp, { op: 'add' }>, id: string): PlanItem {
181
208
  const base = { id, title: op.title, ...(op.description ? { description: op.description } : {}) };
209
+
182
210
  if (op.type === 'section') {
183
211
  return { type: 'section', ...base, items: [] };
184
212
  }
213
+
185
214
  if (op.type === 'text') {
186
215
  return { type: 'text', ...base };
187
216
  }
217
+
188
218
  return { type: 'step', ...base, ...(op.checks ? { checks: op.checks } : {}) };
189
219
  }
190
220
 
191
221
  function applyOne(plan: Plan, op: PlanOp, options: PlanApplyOptions, mint: () => string | PlanRefusal, dropped: string[]): PlanRefusal | null {
192
222
  const verdict = canApply(op, options.actor, plan);
223
+
193
224
  if (!verdict.ok) {
194
225
  return verdict;
195
226
  }
227
+
196
228
  const stepAt = (id: string): PlanStep => locateItem(plan, id)!.item as PlanStep;
229
+
197
230
  switch (op.op) {
198
231
  case 'set': {
199
232
  for (const id of op.ids) {
200
233
  const step = stepAt(id);
201
234
  setState(step, op.state, options.actor, options.now);
235
+
202
236
  if (op.note !== undefined) {
203
237
  setNote(step, op.note);
204
238
  }
205
239
  }
240
+
206
241
  if (op.next !== undefined) {
207
242
  setState(stepAt(op.next), 'active', options.actor, options.now);
208
243
  }
244
+
209
245
  return null;
210
246
  }
211
247
  case 'note':
212
248
  setNote(stepAt(op.id), op.text);
249
+
213
250
  return null;
214
251
  case 'unlock': {
215
- const roots = op.ids === 'all' ? plan.items : op.ids.map((id) => stepAt(id));
252
+ const roots = op.ids === 'all' ? plan.items : op.ids.map(stepAt);
253
+
216
254
  for (const step of allSteps(roots)) {
217
255
  if (effectiveChecks(plan, step) === 'agent') {
218
256
  step.unlocked = true;
219
257
  }
220
258
  }
259
+
221
260
  return null;
222
261
  }
223
262
  case 'add': {
224
263
  const id = op.id ?? mint();
264
+
225
265
  if (typeof id !== 'string') {
226
266
  return id;
227
267
  }
268
+
228
269
  if (op.id !== undefined && locateItem(plan, op.id)) {
229
270
  return refuse('duplicate-id', `The plan already has an item "${op.id}"`);
230
271
  }
272
+
231
273
  if (op.checks !== undefined && op.type !== 'step') {
232
274
  return refuse('plan-invalid', 'Only a step has checks');
233
275
  }
276
+
234
277
  const place = placeFor(plan, op.type, op.under, op.after);
278
+
235
279
  if ('ok' in place) {
236
280
  return place;
237
281
  }
282
+
238
283
  place.siblings.splice(place.index, 0, newItem(op, id));
239
284
  becomeParent(place.parent, dropped);
285
+
240
286
  return null;
241
287
  }
242
288
  case 'edit': {
243
289
  const item = locateItem(plan, op.id)!.item;
290
+
244
291
  if (op.checks !== undefined && item.type !== 'step') {
245
292
  return refuse('plan-invalid', 'Only a step has checks');
246
293
  }
294
+
247
295
  if (op.title !== undefined) {
248
296
  item.title = op.title;
249
297
  }
298
+
250
299
  if (op.description === '') {
251
300
  delete item.description;
252
301
  } else if (op.description !== undefined) {
253
302
  item.description = op.description;
254
303
  }
304
+
255
305
  if (op.checks !== undefined && item.type === 'step') {
256
306
  item.checks = op.checks;
257
307
  }
308
+
258
309
  return null;
259
310
  }
260
311
  case 'move': {
261
312
  const location = locateItem(plan, op.id)!;
313
+
262
314
  if (op.after === op.id || (op.under !== undefined && allItems([location.item]).some((item) => item.id === op.under))) {
263
315
  return refuse('plan-bad-position', `"${op.id}" cannot move into itself`);
264
316
  }
317
+
265
318
  location.siblings.splice(location.index, 1);
266
319
  const place = placeFor(plan, location.item.type, op.under, op.after);
320
+
267
321
  if ('ok' in place) {
268
322
  return place;
269
323
  }
324
+
270
325
  place.siblings.splice(place.index, 0, location.item);
271
326
  becomeParent(place.parent, dropped);
327
+
272
328
  return null;
273
329
  }
274
330
  case 'remove': {
275
331
  const location = locateItem(plan, op.id)!;
276
332
  location.siblings.splice(location.index, 1);
333
+
277
334
  return null;
278
335
  }
279
336
  case 'meta': {
280
337
  if (op.title !== undefined) {
281
338
  plan.meta.title = op.title;
282
339
  }
340
+
283
341
  for (const key of ['summary', 'status'] as const) {
284
342
  if (op[key] === '') {
285
343
  delete plan.meta[key];
@@ -287,9 +345,11 @@ function applyOne(plan: Plan, op: PlanOp, options: PlanApplyOptions, mint: () =>
287
345
  plan.meta[key] = op[key];
288
346
  }
289
347
  }
348
+
290
349
  if (op.checks !== undefined) {
291
350
  plan.meta.checks = op.checks;
292
351
  }
352
+
293
353
  return null;
294
354
  }
295
355
  }
@@ -302,39 +362,50 @@ function applyOne(plan: Plan, op: PlanOp, options: PlanApplyOptions, mint: () =>
302
362
  */
303
363
  export function applyPlanOps(plan: Plan, ops: readonly PlanOp[], options: PlanApplyOptions): PlanApplied | PlanRefusal {
304
364
  const parsedOps = z.array(PlanOpSchema).min(1).safeParse(ops);
365
+
305
366
  if (!parsedOps.success) {
306
367
  return refuse('plan-invalid', issueMessage(parsedOps.error));
307
368
  }
369
+
308
370
  const next = structuredClone(plan);
309
371
  const minted: string[] = [];
310
372
  const dropped: string[] = [];
311
373
  const taken = new Set(allItems(next.items).map((item) => item.id));
312
374
  const mint = idMinter(taken, options.mintId ?? randomItemId, minted);
375
+
313
376
  for (const op of parsedOps.data) {
314
377
  const problem = applyOne(next, op, options, mint, dropped);
378
+
315
379
  if (problem) {
316
380
  return problem;
317
381
  }
382
+
318
383
  for (const item of allItems(next.items)) {
319
384
  taken.add(item.id);
320
385
  }
321
386
  }
387
+
322
388
  pruneEmptySteps(next.items);
323
389
  next.rev = plan.rev + 1;
324
390
  const checked = validatePlan(next);
391
+
325
392
  if (!checked.ok) {
326
393
  return checked;
327
394
  }
395
+
328
396
  return { ok: true, plan: checked.plan, minted, dropped };
329
397
  }
330
398
 
331
399
  /* A plan as it is stored or sent: the schema, then the rules that need the whole tree. */
332
400
  export function validatePlan(value: unknown): { ok: true; plan: Plan } | PlanRefusal {
333
401
  const parsed = PlanSchema.safeParse(value);
402
+
334
403
  if (!parsed.success) {
335
404
  return refuse('plan-invalid', issueMessage(parsed.error));
336
405
  }
406
+
337
407
  const problem = structureProblem(parsed.data);
408
+
338
409
  return problem ?? { ok: true, plan: parsed.data };
339
410
  }
340
411
 
@@ -386,6 +457,7 @@ export type PlanDraft = z.infer<typeof PlanDraftSchema>;
386
457
 
387
458
  export function parsePlanDraft(value: unknown): { ok: true; draft: PlanDraft } | PlanRefusal {
388
459
  const parsed = PlanDraftSchema.safeParse(value);
460
+
389
461
  return parsed.success ? { ok: true, draft: parsed.data } : refuse('plan-invalid', issueMessage(parsed.error));
390
462
  }
391
463
 
@@ -400,14 +472,18 @@ export interface PlanCreateOptions {
400
472
  /* A new plan from an agent's draft: ids minted, states marked as the agent's, and the same rules as any later change. */
401
473
  export function createPlan(draft: PlanDraft, options: PlanCreateOptions): PlanApplied | PlanRefusal {
402
474
  const parsed = PlanDraftSchema.safeParse(draft);
475
+
403
476
  if (!parsed.success) {
404
477
  return refuse('plan-invalid', issueMessage(parsed.error));
405
478
  }
479
+
406
480
  const source = parsed.data;
407
481
  const title = options.meta?.title ?? source.meta?.title;
482
+
408
483
  if (!title) {
409
484
  return refuse('plan-invalid', 'A plan needs a title');
410
485
  }
486
+
411
487
  const meta: PlanMeta = {
412
488
  title,
413
489
  kind: options.meta?.kind ?? source.meta?.kind ?? 'steps',
@@ -415,66 +491,88 @@ export function createPlan(draft: PlanDraft, options: PlanCreateOptions): PlanAp
415
491
  ...(source.meta?.status ? { status: source.meta.status } : {}),
416
492
  checks: options.meta?.checks ?? source.meta?.checks ?? 'anyone'
417
493
  };
494
+
418
495
  const minted: string[] = [];
419
496
  const taken = new Set<string>();
497
+
420
498
  for (const item of allDraftItems(source.items)) {
421
499
  if (item.id !== undefined) {
422
500
  if (taken.has(item.id)) {
423
501
  return refuse('duplicate-id', `Two items share the id "${item.id}"`);
424
502
  }
503
+
425
504
  taken.add(item.id);
426
505
  }
427
506
  }
507
+
428
508
  const mint = idMinter(taken, options.mintId ?? randomItemId, minted);
429
509
  // Minted up front so a refusal stops here; the tree below takes them in the same order, parents first.
430
510
  const fresh: string[] = [];
511
+
431
512
  for (const item of allDraftItems(source.items)) {
432
513
  if (item.id === undefined) {
433
514
  const id = mint();
515
+
434
516
  if (typeof id !== 'string') {
435
517
  return id;
436
518
  }
519
+
437
520
  fresh.push(id);
438
521
  }
439
522
  }
523
+
440
524
  const nextId = (): string => fresh.shift()!;
525
+
441
526
  const described = <T extends { description?: string }>(value: T): T => {
442
527
  if (value.description === '') {
443
528
  delete value.description;
444
529
  }
530
+
445
531
  return value;
446
532
  };
533
+
447
534
  const toStep = (step: PlanDraftStep): PlanStep => {
448
535
  const { steps, state, note, ...rest } = step;
449
536
  const result: PlanStep = described({ ...rest, id: step.id ?? nextId() });
537
+
450
538
  if (steps && steps.length > 0) {
451
539
  result.steps = steps.map(toStep);
452
540
  }
541
+
453
542
  if (state !== undefined) {
454
543
  Object.assign(result, { state, by: 'agent', at: options.now });
455
544
  }
545
+
456
546
  if (note) {
457
547
  result.note = note;
458
548
  }
549
+
459
550
  return result;
460
551
  };
552
+
461
553
  const toText = (text: PlanDraftText): PlanText => described({ ...text, id: text.id ?? nextId() });
554
+
462
555
  const items = source.items.map((item): PlanItem => {
463
556
  if (item.type === 'section') {
464
557
  return described({ ...item, id: item.id ?? nextId(), items: item.items.map((child) => (child.type === 'text' ? toText(child) : toStep(child))) });
465
558
  }
559
+
466
560
  return item.type === 'text' ? toText(item) : toStep(item);
467
561
  });
562
+
468
563
  const plan: Plan = { id: options.id, rev: 0, createdAt: options.now, meta, items };
469
564
  const checked = validatePlan(plan);
565
+
470
566
  if (!checked.ok) {
471
567
  return checked;
472
568
  }
569
+
473
570
  for (const step of allSteps(checked.plan.items)) {
474
571
  if (step.state !== undefined && effectiveChecks(checked.plan, step) === 'person') {
475
572
  return refuse('person-only', `Only a person checks "${step.id}"`);
476
573
  }
477
574
  }
575
+
478
576
  return { ok: true, plan: checked.plan, minted, dropped: [] };
479
577
  }
480
578
 
package/src/markdown.ts CHANGED
@@ -51,37 +51,48 @@ function dropEscape(line: string): string {
51
51
  */
52
52
  export function planToMarkdown(plan: Plan): string {
53
53
  const blocks: string[] = [`# ${plan.meta.title}`];
54
+
54
55
  if (plan.meta.summary) {
55
56
  blocks.push(escapeText(plan.meta.summary));
56
57
  }
58
+
57
59
  const stepLines = (step: PlanStep, depth: number): string[] => {
58
60
  const indent = INDENT.repeat(depth);
59
61
  const inner = INDENT.repeat(depth + 1);
60
62
  const lines = [`${indent}- ${PLAN_STATE_MARKERS[stepState(step)]} ${step.title}`];
63
+
61
64
  for (const line of step.description?.split('\n') ?? []) {
62
65
  lines.push(line === '' ? '' : `${inner}${escapeLine(line)}`);
63
66
  }
67
+
64
68
  for (const line of step.note?.split('\n') ?? []) {
65
69
  lines.push(`${inner}> ${line}`);
66
70
  }
71
+
67
72
  for (const child of step.steps ?? []) {
68
73
  lines.push(...stepLines(child, depth + 1));
69
74
  }
75
+
70
76
  return lines;
71
77
  };
78
+
72
79
  const textBlock = (item: Extract<PlanItem, { type: 'text' }>): string => {
73
80
  const [first = '', ...rest] = item.description?.split('\n') ?? [];
81
+
74
82
  return [`> **${escapeBold(item.title)}**${first ? ` ${first}` : ''}`, ...rest.map((line) => `> ${escapeQuoted(line)}`)].join('\n');
75
83
  };
84
+
76
85
  const itemBlocks = (items: readonly PlanItem[]): string[] => {
77
86
  const result: string[] = [];
78
87
  let list: string[] = [];
88
+
79
89
  const flush = (): void => {
80
90
  if (list.length > 0) {
81
91
  result.push(list.join('\n'));
82
92
  list = [];
83
93
  }
84
94
  };
95
+
85
96
  for (const item of items) {
86
97
  if (item.type === 'step') {
87
98
  list.push(...stepLines(item, 0));
@@ -90,11 +101,15 @@ export function planToMarkdown(plan: Plan): string {
90
101
  result.push(textBlock(item));
91
102
  }
92
103
  }
104
+
93
105
  flush();
106
+
94
107
  return result;
95
108
  };
109
+
96
110
  let inSection = false;
97
111
  let loose: PlanItem[] = [];
112
+
98
113
  const flushLoose = (): void => {
99
114
  if (loose.length > 0) {
100
115
  // Only a rule brings the items after a section back to the top of the plan.
@@ -102,29 +117,38 @@ export function planToMarkdown(plan: Plan): string {
102
117
  blocks.push('---');
103
118
  inSection = false;
104
119
  }
120
+
105
121
  blocks.push(...itemBlocks(loose));
106
122
  loose = [];
107
123
  }
108
124
  };
125
+
109
126
  for (const item of plan.items) {
110
127
  if (item.type !== 'section') {
111
128
  loose.push(item);
129
+
112
130
  continue;
113
131
  }
132
+
114
133
  flushLoose();
115
134
  blocks.push(`## ${item.title}`);
135
+
116
136
  if (item.description) {
117
137
  blocks.push(escapeText(item.description));
118
138
  }
139
+
119
140
  blocks.push(...itemBlocks(item.items));
120
141
  inSection = true;
121
142
  }
143
+
122
144
  flushLoose();
145
+
123
146
  return `${blocks.join('\n\n')}\n`;
124
147
  }
125
148
 
126
149
  function indentOf(line: string): number {
127
150
  let width = 0;
151
+
128
152
  for (const char of line) {
129
153
  if (char === ' ') {
130
154
  width++;
@@ -134,6 +158,7 @@ function indentOf(line: string): number {
134
158
  break;
135
159
  }
136
160
  }
161
+
137
162
  return width;
138
163
  }
139
164
 
@@ -153,37 +178,50 @@ export function parsePlanMarkdown(markdown: string): { ok: true; draft: PlanDraf
153
178
  // The text the last line of prose went on and the blank lines since, so a paragraph break inside it stays.
154
179
  let continuing: object | null = null;
155
180
  let blanks = 0;
181
+
156
182
  const container = (): PlanDraftItem[] => (section ? section.items : draft.items) as PlanDraftItem[];
183
+
157
184
  const popTo = (indent: number): void => {
158
- while (stack.length > 0 && stack[stack.length - 1]!.indent >= indent) {
185
+ while ((stack.at(-1)?.indent ?? -1) >= indent) {
159
186
  stack.pop();
160
187
  }
161
188
  };
189
+
162
190
  const lines = markdown.replace(/\r\n?/g, '\n').split('\n');
191
+
163
192
  for (let index = 0; index < lines.length; index++) {
164
193
  const raw = lines[index]!.trimEnd();
165
194
  const content = raw.trim();
166
195
  const indent = indentOf(raw);
196
+
167
197
  const invalid = (message: string): PlanRefusal => refuse('plan-invalid', `Line ${index + 1}: ${message}`);
198
+
168
199
  if (content === '') {
169
200
  text = null;
170
201
  blanks++;
202
+
171
203
  continue;
172
204
  }
205
+
173
206
  const previous = continuing;
174
207
  const gap = blanks;
175
208
  continuing = null;
176
209
  blanks = 0;
210
+
177
211
  const blanksBefore = (target: object): number => {
178
212
  continuing = target;
213
+
179
214
  return previous === target ? gap : 0;
180
215
  };
216
+
181
217
  const plain = dropEscape(content);
182
218
  const heading = /^(#{1,6})\s+(.*)$/.exec(content);
219
+
183
220
  if (heading && indent < 4) {
184
221
  text = null;
185
222
  stack = [];
186
223
  const title = heading[2]!.trim();
224
+
187
225
  if (heading[1] === '#' && draft.meta?.title === undefined && draft.items.length === 0 && !section) {
188
226
  draft.meta = { ...draft.meta, title };
189
227
  } else if (heading[1] === '##') {
@@ -192,52 +230,70 @@ export function parsePlanMarkdown(markdown: string): { ok: true; draft: PlanDraf
192
230
  } else {
193
231
  return invalid('only one "#" title before everything else and "##" sections are headings in a plan');
194
232
  }
233
+
195
234
  continue;
196
235
  }
236
+
197
237
  if (/^(-{3,}|\*{3,}|_{3,})$/.test(content) && indent < 4) {
198
238
  section = null;
199
239
  stack = [];
200
240
  text = null;
241
+
201
242
  continue;
202
243
  }
244
+
203
245
  const item = /^[-*+]\s+(?:\[(.)\]\s+)?(.*)$/.exec(content);
246
+
204
247
  if (item) {
205
248
  text = null;
206
249
  const marker = item[1];
250
+
207
251
  if (marker !== undefined && !(marker in MARKER_STATES)) {
208
252
  return invalid(`"[${marker}]" is not a step marker`);
209
253
  }
254
+
210
255
  const title = item[2]!.trim();
256
+
211
257
  if (title === '') {
212
258
  return invalid('a step needs a title');
213
259
  }
260
+
214
261
  const state = marker === undefined ? undefined : MARKER_STATES[marker];
215
262
  const step: PlanDraftStep = { type: 'step', title, ...(state ? { state } : {}) };
216
263
  popTo(indent);
217
- const parent = stack[stack.length - 1]?.step;
264
+ const parent = stack.at(-1)?.step;
265
+
218
266
  if (parent) {
219
267
  parent.steps ??= [];
220
268
  parent.steps.push(step);
221
269
  } else {
222
270
  container().push(step);
223
271
  }
272
+
224
273
  stack.push({ indent, step });
274
+
225
275
  continue;
226
276
  }
277
+
227
278
  const quote = /^>\s?(.*)$/.exec(content);
228
279
  popTo(indent);
229
- const owner = stack[stack.length - 1]?.step;
280
+ const owner = stack.at(-1)?.step;
281
+
230
282
  if (owner && indent > 0) {
231
283
  if (quote) {
232
284
  owner.note = appendLine(owner.note, quote[1]!);
233
285
  } else {
234
286
  owner.description = appendLine(owner.description, plain, blanksBefore(owner));
235
287
  }
288
+
236
289
  continue;
237
290
  }
291
+
238
292
  stack = [];
293
+
239
294
  if (quote) {
240
295
  const titled = /^\*\*((?:\\.|[^\\])+?)\*\*\s*(.*)$/.exec(quote[1]!.trim());
296
+
241
297
  if (titled) {
242
298
  const title = titled[1]!.trim().replace(/\\([\\*])/g, '$1');
243
299
  text = { type: 'text', title, ...(titled[2] ? { description: titled[2] } : {}) };
@@ -247,18 +303,25 @@ export function parsePlanMarkdown(markdown: string): { ok: true; draft: PlanDraf
247
303
  } else {
248
304
  return invalid('a text block starts with a bold title: > **Title** description');
249
305
  }
306
+
250
307
  continue;
251
308
  }
309
+
252
310
  if (!section && draft.items.length === 0) {
253
311
  draft.meta = { ...draft.meta, summary: appendLine(draft.meta?.summary, plain, blanksBefore(draft)) };
312
+
254
313
  continue;
255
314
  }
315
+
256
316
  if (section && section.items.length === 0) {
257
317
  section.description = appendLine(section.description, plain, blanksBefore(section));
318
+
258
319
  continue;
259
320
  }
321
+
260
322
  return invalid('this line is not a step, a section, a text block or a description');
261
323
  }
324
+
262
325
  // A parent's state follows from its children, so a marker on it says nothing.
263
326
  const clearParents = (items: readonly PlanDraftItem[]): void => {
264
327
  for (const entry of items) {
@@ -270,6 +333,8 @@ export function parsePlanMarkdown(markdown: string): { ok: true; draft: PlanDraf
270
333
  }
271
334
  }
272
335
  };
336
+
273
337
  clearParents(draft.items);
338
+
274
339
  return { ok: true, draft };
275
340
  }
@@ -10,29 +10,37 @@ const AGENT_OPS: ReadonlySet<PlanOp['op']> = new Set(['set', 'note', 'add', 'edi
10
10
 
11
11
  function stepFor(plan: Plan, id: string, leaf: boolean): PlanStep | PlanRefusal {
12
12
  const location = locateItem(plan, id);
13
+
13
14
  if (!location) {
14
15
  return missingItem(id);
15
16
  }
17
+
16
18
  if (location.item.type !== 'step') {
17
19
  return refuse('plan-not-a-step', `"${id}" is a ${location.item.type}, not a step`);
18
20
  }
21
+
19
22
  if (leaf && isParentStep(location.item)) {
20
23
  return refuse('plan-parent-state', `The step "${id}" has sub-steps, so its state follows from them`);
21
24
  }
25
+
22
26
  return location.item;
23
27
  }
24
28
 
25
29
  function mayState(plan: Plan, step: PlanStep, state: PlanStepState, actor: PlanActor): PlanVerdict {
26
30
  const checks = effectiveChecks(plan, step);
31
+
27
32
  if (actor === 'person') {
28
33
  return checks === 'agent' ? refuse('step-locked', `Only the agent checks "${step.id}" until a person unlocks it`) : ALLOWED;
29
34
  }
35
+
30
36
  if (checks === 'person') {
31
37
  return refuse('person-only', `Only a person checks "${step.id}"`);
32
38
  }
39
+
33
40
  if (step.by === 'person' && step.state !== undefined && step.state !== state) {
34
41
  return refuse('set-by-person', `A person set "${step.id}" to ${step.state}; add a note or ask in the chat instead`);
35
42
  }
43
+
36
44
  return ALLOWED;
37
45
  }
38
46
 
@@ -42,15 +50,19 @@ function mayState(plan: Plan, step: PlanStep, state: PlanStepState, actor: PlanA
42
50
  */
43
51
  function mayHoldChildren(plan: Plan, under: string | undefined, after: string | undefined, added: readonly PlanChecks[]): PlanVerdict {
44
52
  const parent = under !== undefined ? locateItem(plan, under)?.item : after !== undefined ? locateItem(plan, after)?.parent : null;
53
+
45
54
  if (parent?.type !== 'step') {
46
55
  return ALLOWED;
47
56
  }
57
+
48
58
  if (!isParentStep(parent) && holdsPersonState(parent)) {
49
59
  return refuse('set-by-person', `A person set "${parent.id}" to ${parent.state}; a sub-step would take that state away`);
50
60
  }
61
+
51
62
  if (effectiveChecks(plan, parent) === 'person' && added.some((checks) => checks !== 'person')) {
52
63
  return refuse('person-only', `Only a person checks "${parent.id}", so a sub-step under it is one a person checks too`);
53
64
  }
65
+
54
66
  return ALLOWED;
55
67
  }
56
68
 
@@ -63,79 +75,102 @@ export function canApply(op: PlanOp, actor: PlanActor, plan: Plan): PlanVerdict
63
75
  if (!(actor === 'person' ? PERSON_OPS : AGENT_OPS).has(op.op)) {
64
76
  return refuse('op-not-allowed', actor === 'person' ? `A person cannot ${op.op} in a plan` : `An agent cannot ${op.op} a plan`);
65
77
  }
78
+
66
79
  switch (op.op) {
67
80
  case 'set': {
68
81
  if (op.next !== undefined && actor === 'person') {
69
82
  return refuse('op-not-allowed', 'Only the agent moves on to a next step');
70
83
  }
84
+
71
85
  const targets: [string, PlanStepState][] = op.ids.map((id) => [id, op.state]);
86
+
72
87
  if (op.next !== undefined) {
73
88
  targets.push([op.next, 'active']);
74
89
  }
90
+
75
91
  for (const [id, state] of targets) {
76
92
  const step = stepFor(plan, id, true);
93
+
77
94
  if ('ok' in step) {
78
95
  return step;
79
96
  }
97
+
80
98
  const verdict = mayState(plan, step, state, actor);
99
+
81
100
  if (!verdict.ok) {
82
101
  return verdict;
83
102
  }
84
103
  }
104
+
85
105
  return ALLOWED;
86
106
  }
87
107
  case 'note': {
88
108
  const step = stepFor(plan, op.id, false);
109
+
89
110
  return 'ok' in step ? step : ALLOWED;
90
111
  }
91
112
  case 'unlock': {
92
113
  if (op.ids === 'all') {
93
114
  return ALLOWED;
94
115
  }
116
+
95
117
  for (const id of op.ids) {
96
118
  const step = stepFor(plan, id, false);
119
+
97
120
  if ('ok' in step) {
98
121
  return step;
99
122
  }
100
123
  }
124
+
101
125
  return ALLOWED;
102
126
  }
103
127
  case 'add':
104
128
  return mayHoldChildren(plan, op.under, op.after, op.type === 'step' ? [op.checks ?? plan.meta.checks] : []);
105
129
  case 'edit': {
106
130
  const location = locateItem(plan, op.id);
131
+
107
132
  if (!location) {
108
133
  return missingItem(op.id);
109
134
  }
135
+
110
136
  const item = location.item;
137
+
111
138
  if (item.type !== 'step') {
112
139
  return ALLOWED;
113
140
  }
141
+
114
142
  // A new title would make a person's check or stamp stand for something they never saw.
115
143
  if (op.title !== undefined && op.title !== item.title) {
116
144
  if (holdsPersonState(item)) {
117
145
  return refuse('set-by-person', `A person checked "${op.id}" or a step under it, so its title stays`);
118
146
  }
147
+
119
148
  if (effectiveChecks(plan, item) === 'person') {
120
149
  return refuse('person-only', `Only a person checks "${op.id}", so its title stays`);
121
150
  }
122
151
  }
152
+
123
153
  if (op.checks === undefined) {
124
154
  return ALLOWED;
125
155
  }
156
+
126
157
  if (item.unlocked && op.checks !== 'anyone') {
127
158
  return refuse('unlocked-by-person', `A person unlocked "${op.id}", so anyone checks it`);
128
159
  }
160
+
129
161
  if (effectiveChecks(plan, item) === 'person' && op.checks !== 'person') {
130
162
  return refuse('person-only', `Only a person checks "${op.id}", and that stays so`);
131
163
  }
164
+
132
165
  return ALLOWED;
133
166
  }
134
167
  case 'move': {
135
168
  const location = locateItem(plan, op.id);
169
+
136
170
  if (!location) {
137
171
  return missingItem(op.id);
138
172
  }
173
+
139
174
  return mayHoldChildren(
140
175
  plan,
141
176
  op.under,
@@ -145,22 +180,28 @@ export function canApply(op: PlanOp, actor: PlanActor, plan: Plan): PlanVerdict
145
180
  }
146
181
  case 'remove': {
147
182
  const location = locateItem(plan, op.id);
183
+
148
184
  if (!location) {
149
185
  return missingItem(op.id);
150
186
  }
187
+
151
188
  if (holdsPersonState(location.item)) {
152
189
  return refuse('set-by-person', `A person checked "${op.id}" or a step under it, so it stays`);
153
190
  }
191
+
154
192
  if (allSteps([location.item]).some((step) => effectiveChecks(plan, step) === 'person')) {
155
193
  return refuse('person-only', `Only a person checks "${op.id}" or a step under it, so it stays`);
156
194
  }
195
+
157
196
  return ALLOWED;
158
197
  }
159
198
  case 'meta': {
160
199
  const inherits = allSteps(plan.items).some((step) => step.checks === undefined && !step.unlocked);
200
+
161
201
  if (op.checks !== undefined && op.checks !== 'person' && plan.meta.checks === 'person' && inherits) {
162
202
  return refuse('person-only', 'Only a person checks the steps of this plan, and that stays so');
163
203
  }
204
+
164
205
  return ALLOWED;
165
206
  }
166
207
  }
package/src/text.ts CHANGED
@@ -23,9 +23,11 @@ export interface PlanTextOptions {
23
23
 
24
24
  function localTime(at: string): string {
25
25
  const date = new Date(at);
26
+
26
27
  if (Number.isNaN(date.getTime())) {
27
28
  return '';
28
29
  }
30
+
29
31
  return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`;
30
32
  }
31
33
 
@@ -43,27 +45,27 @@ function counter(progress: PlanProgress): string {
43
45
 
44
46
  export function progressText(plan: Pick<Plan, 'meta' | 'items'>): string {
45
47
  const progress = planProgress(plan.items);
48
+
46
49
  const extra = (count: number, label: string): string[] => (count > 0 ? [`${count} ${label}`] : []);
47
- const warnings = progress.warning === 1 ? 'warning' : 'warnings';
50
+
51
+ const unfinished = [...extra(progress.failed, 'failed'), ...extra(progress.skipped, 'skipped'), ...extra(progress.blocked, 'blocked')];
52
+
48
53
  if (plan.meta.kind === 'test') {
49
54
  return [
50
55
  `${progress.finished} of ${progress.total} run`,
51
56
  `${progress.done} passed`,
52
- ...extra(progress.warning, warnings),
57
+ ...extra(progress.warning, progress.warning === 1 ? 'warning' : 'warnings'),
53
58
  ...extra(progress.info, 'info'),
54
- ...extra(progress.failed, 'failed'),
55
- ...extra(progress.skipped, 'skipped'),
56
- ...extra(progress.blocked, 'blocked')
59
+ ...unfinished
57
60
  ].join(', ');
58
61
  }
62
+
59
63
  // In a steps plan a warning or info is still done; the extras say which of the done steps to read.
60
64
  return [
61
65
  `${progress.done + progress.warning + progress.info} of ${progress.total} done`,
62
66
  ...extra(progress.warning, `with ${progress.warning === 1 ? 'a warning' : 'warnings'}`),
63
67
  ...extra(progress.info, 'with info'),
64
- ...extra(progress.failed, 'failed'),
65
- ...extra(progress.skipped, 'skipped'),
66
- ...extra(progress.blocked, 'blocked')
68
+ ...unfinished
67
69
  ].join(', ');
68
70
  }
69
71
 
@@ -80,52 +82,66 @@ export function renderPlanText(plan: Plan, options: PlanTextOptions = {}): strin
80
82
  const formatTime = options.formatTime ?? localTime;
81
83
  const lines = [`Plan "${plan.meta.title}" (${plan.id}, ${plan.meta.kind}, rev ${plan.rev}): ${progressText(plan)}`];
82
84
  const second: string[] = [];
85
+
83
86
  if (plan.meta.summary) {
84
87
  second.push(`Summary: ${oneLine(plan.meta.summary)}`);
85
88
  }
89
+
86
90
  if (plan.meta.status) {
87
91
  second.push(`Status: ${plan.meta.status}`);
88
92
  }
93
+
89
94
  const active = leafSteps(plan.items).filter((step) => step.state === 'active');
95
+
90
96
  if (active.length > 0) {
91
97
  second.push(`Now: ${active.map((step) => `"${step.title}" [${step.id}]`).join(', ')}`);
92
98
  }
93
- if (options.others && options.others.length > 0) {
99
+
100
+ if (options.others?.length) {
94
101
  const others = options.others.map((other) => `${other.id} "${other.meta.title}" (${other.meta.kind}, ${counter(planProgress(other.items))})`);
95
102
  second.push(`Also in this chat: ${others.join(', ')}`);
96
103
  }
104
+
97
105
  if (second.length > 0) {
98
106
  lines.push(sentences(second));
99
107
  }
100
108
 
101
109
  const stepLine = (step: PlanStep, number: string, indent: string): void => {
102
110
  let line = `${indent}${PLAN_STATE_MARKERS[stepState(step)]} ${number} ${step.title} [${step.id}]`;
111
+
103
112
  if (isParentStep(step)) {
104
113
  line += ` ${counter(planProgress(step.steps!))}`;
105
114
  } else {
106
115
  const notes: string[] = [];
107
116
  const checks = effectiveChecks(plan, step);
117
+
108
118
  if (checks !== 'anyone') {
109
119
  notes.push(`${checks}-only`);
110
120
  } else if (step.unlocked) {
111
121
  notes.push('unlocked');
112
122
  }
123
+
113
124
  if (step.by === 'person' && step.state !== undefined) {
114
125
  const time = step.at ? formatTime(step.at) : '';
115
126
  notes.push(time ? `set by a person ${time}` : 'set by a person');
116
127
  }
128
+
117
129
  if (notes.length > 0) {
118
130
  line += ` ${notes.join(', ')}`;
119
131
  }
120
132
  }
133
+
121
134
  if (step.note) {
122
135
  line += `: "${oneLine(step.note)}"`;
123
136
  }
137
+
124
138
  lines.push(line, ...descriptionLines(step.description, `${indent} `));
125
139
  step.steps?.forEach((child, index) => stepLine(child, `${number}.${index + 1}`, `${indent} `));
126
140
  };
141
+
127
142
  const itemLines = (items: readonly PlanItem[], indent: string): void => {
128
143
  let number = 0;
144
+
129
145
  for (const item of items) {
130
146
  if (item.type === 'text') {
131
147
  lines.push(`${indent}${item.title} [${item.id}]${item.description ? `: ${oneLine(item.description)}` : ''}`);
@@ -137,6 +153,7 @@ export function renderPlanText(plan: Plan, options: PlanTextOptions = {}): strin
137
153
  };
138
154
 
139
155
  let loose: PlanItem[] = [];
156
+
140
157
  const flushLoose = (): void => {
141
158
  if (loose.length > 0) {
142
159
  lines.push('');
@@ -144,16 +161,21 @@ export function renderPlanText(plan: Plan, options: PlanTextOptions = {}): strin
144
161
  loose = [];
145
162
  }
146
163
  };
164
+
147
165
  for (const item of plan.items) {
148
166
  if (item.type !== 'section') {
149
167
  loose.push(item);
168
+
150
169
  continue;
151
170
  }
171
+
152
172
  flushLoose();
153
173
  const progress = planProgress(item.items);
154
174
  lines.push('', `## ${item.title} [${item.id}]${progress.total > 0 ? ` ${counter(progress)}` : ''}`, ...descriptionLines(item.description, ' '));
155
175
  itemLines(item.items, ' ');
156
176
  }
177
+
157
178
  flushLoose();
179
+
158
180
  return lines.join('\n');
159
181
  }
package/src/tree.ts CHANGED
@@ -45,9 +45,11 @@ function childrenOf(item: PlanItem): PlanItem[] {
45
45
  if (item.type === 'section') {
46
46
  return item.items;
47
47
  }
48
+
48
49
  if (item.type === 'step') {
49
50
  return item.steps ?? [];
50
51
  }
52
+
51
53
  return [];
52
54
  }
53
55
 
@@ -56,16 +58,21 @@ export function locateItem(plan: Pick<Plan, 'items'>, id: string): PlanLocation
56
58
  for (let index = 0; index < siblings.length; index++) {
57
59
  const item = siblings[index]!;
58
60
  const itemDepth = item.type === 'step' ? depth + 1 : 0;
61
+
59
62
  if (item.id === id) {
60
63
  return { item, siblings, index, parent, depth: itemDepth };
61
64
  }
65
+
62
66
  const found = search(childrenOf(item), item.type === 'text' ? null : item, item.type === 'step' ? itemDepth : depth);
67
+
63
68
  if (found) {
64
69
  return found;
65
70
  }
66
71
  }
72
+
67
73
  return null;
68
74
  };
75
+
69
76
  return search(plan.items, null, 0);
70
77
  }
71
78
 
@@ -100,16 +107,20 @@ export function deriveState(states: readonly PlanStepState[]): PlanStepState {
100
107
  if (states.includes('failed')) {
101
108
  return 'failed';
102
109
  }
110
+
103
111
  if (states.includes('blocked')) {
104
112
  return 'blocked';
105
113
  }
114
+
106
115
  if (states.every(isFinishedOutcome)) {
107
116
  return states.includes('warning') ? 'warning' : 'done';
108
117
  }
118
+
109
119
  // A skipped step did not run, so next to open steps alone it moves nothing forward.
110
120
  if (states.some((state) => state === 'active' || state === 'done' || state === 'warning' || state === 'info')) {
111
121
  return 'active';
112
122
  }
123
+
113
124
  return 'open';
114
125
  }
115
126
 
@@ -135,14 +146,17 @@ export interface PlanProgress {
135
146
 
136
147
  export function planProgress(items: readonly PlanItem[]): PlanProgress {
137
148
  const progress: PlanProgress = { total: 0, open: 0, active: 0, done: 0, failed: 0, skipped: 0, blocked: 0, warning: 0, info: 0, finished: 0 };
149
+
138
150
  for (const step of leafSteps(items)) {
139
151
  const state = step.state ?? 'open';
140
152
  progress.total++;
141
153
  progress[state]++;
154
+
142
155
  if (state === 'failed' || isFinishedOutcome(state)) {
143
156
  progress.finished++;
144
157
  }
145
158
  }
159
+
146
160
  return progress;
147
161
  }
148
162
 
@@ -169,42 +183,56 @@ export function holdsPersonState(item: PlanItem): boolean {
169
183
  export function structureProblem(plan: Pick<Plan, 'items'>): PlanRefusal | null {
170
184
  const ids = new Set<string>();
171
185
  let count = 0;
186
+
172
187
  const walk = (items: readonly PlanItem[], depth: number, inside: 'top' | 'section' | 'step'): PlanRefusal | null => {
173
188
  for (const item of items) {
174
189
  count++;
190
+
175
191
  if (count > PLAN_LIMITS.items) {
176
192
  return refuse('plan-too-large', `A plan holds at most ${PLAN_LIMITS.items} items`);
177
193
  }
194
+
178
195
  if (ids.has(item.id)) {
179
196
  return refuse('duplicate-id', `Two items share the id "${item.id}"`);
180
197
  }
198
+
181
199
  ids.add(item.id);
200
+
182
201
  if (item.type === 'section' && inside !== 'top') {
183
202
  return refuse('plan-bad-position', `The section "${item.id}" is not at the top of the plan; sections do not nest`);
184
203
  }
204
+
185
205
  if (item.type === 'text' && inside === 'step') {
186
206
  return refuse('plan-bad-position', `The text block "${item.id}" is under a step; a step only holds steps`);
187
207
  }
208
+
188
209
  if (item.type === 'step') {
189
210
  if (depth + 1 > PLAN_LIMITS.depth) {
190
211
  return refuse('plan-too-deep', `The step "${item.id}" is ${depth + 1} levels deep; steps go at most ${PLAN_LIMITS.depth} levels deep`);
191
212
  }
213
+
192
214
  if (isParentStep(item) && (item.state !== undefined || item.by !== undefined || item.at !== undefined)) {
193
215
  return refuse('plan-parent-state', `The step "${item.id}" has sub-steps, so its state follows from them`);
194
216
  }
217
+
195
218
  const problem = walk(item.steps ?? [], depth + 1, 'step');
219
+
196
220
  if (problem) {
197
221
  return problem;
198
222
  }
199
223
  }
224
+
200
225
  if (item.type === 'section') {
201
226
  const problem = walk(item.items, depth, 'section');
227
+
202
228
  if (problem) {
203
229
  return problem;
204
230
  }
205
231
  }
206
232
  }
233
+
207
234
  return null;
208
235
  };
236
+
209
237
  return walk(plan.items, 0, 'top');
210
238
  }