@adecore/plan 0.38.0 → 0.40.0
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 +2 -3
- package/dist/markdown.js +3 -3
- package/dist/text.js +5 -9
- package/package.json +4 -4
- package/src/apply.ts +101 -3
- package/src/markdown.ts +68 -3
- package/src/permissions.ts +41 -0
- package/src/text.ts +31 -9
- package/src/tree.ts +28 -0
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(
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
-
...
|
|
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
|
-
...
|
|
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
|
|
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.
|
|
3
|
+
"version": "0.40.0",
|
|
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.
|
|
44
|
-
"@types/node": "^24.
|
|
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.
|
|
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
|
-
|
|
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(
|
|
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.
|
|
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
|
|
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
|
|
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
|
}
|
package/src/permissions.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
...
|
|
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
|
-
...
|
|
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
|
-
|
|
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
|
}
|