@adecore/plan 0.0.1 → 0.17.0-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +110 -0
- package/README.md +50 -0
- package/dist/apply.d.ts +252 -0
- package/dist/apply.js +379 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/markdown.d.ts +8 -0
- package/dist/markdown.js +271 -0
- package/dist/permissions.d.ts +7 -0
- package/dist/permissions.js +152 -0
- package/dist/protocol.d.ts +478 -0
- package/dist/protocol.js +148 -0
- package/dist/text.d.ts +9 -0
- package/dist/text.js +143 -0
- package/dist/tree.d.ts +41 -0
- package/dist/tree.js +142 -0
- package/package.json +40 -2
- package/src/apply.ts +452 -0
- package/src/index.ts +46 -0
- package/src/markdown.ts +275 -0
- package/src/permissions.ts +162 -0
- package/src/protocol.ts +194 -0
- package/src/text.ts +159 -0
- package/src/tree.ts +206 -0
package/dist/protocol.js
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
// Depth and item count require the whole tree and are checked by validatePlan.
|
|
3
|
+
export const PLAN_LIMITS = {
|
|
4
|
+
idLength: 64,
|
|
5
|
+
title: 200,
|
|
6
|
+
description: 1000,
|
|
7
|
+
note: 500,
|
|
8
|
+
status: 200,
|
|
9
|
+
depth: 5,
|
|
10
|
+
items: 300,
|
|
11
|
+
plansPerChat: 20
|
|
12
|
+
};
|
|
13
|
+
export const PlanItemIdSchema = z
|
|
14
|
+
.string()
|
|
15
|
+
.min(1)
|
|
16
|
+
.max(PLAN_LIMITS.idLength)
|
|
17
|
+
.regex(/^[a-z0-9-]+$/);
|
|
18
|
+
export const PlanIdSchema = PlanItemIdSchema;
|
|
19
|
+
// Persisted consumers validate the entire enum; new fields must remain optional.
|
|
20
|
+
export const PlanStepStateSchema = z.enum(['open', 'active', 'done', 'failed', 'skipped', 'blocked', 'warning', 'info']);
|
|
21
|
+
export const PlanChecksSchema = z.enum(['anyone', 'agent', 'person']);
|
|
22
|
+
export const PlanActorSchema = z.enum(['person', 'agent']);
|
|
23
|
+
export const PlanKindSchema = z.enum(['steps', 'test']);
|
|
24
|
+
const TitleSchema = z.string().min(1).max(PLAN_LIMITS.title);
|
|
25
|
+
const DescriptionSchema = z.string().max(PLAN_LIMITS.description);
|
|
26
|
+
const NoteSchema = z.string().max(PLAN_LIMITS.note);
|
|
27
|
+
export const PlanMetaSchema = z.object({
|
|
28
|
+
title: TitleSchema,
|
|
29
|
+
kind: PlanKindSchema,
|
|
30
|
+
summary: DescriptionSchema.optional(),
|
|
31
|
+
// One line under the title, for a plan that tracks progress.
|
|
32
|
+
status: z.string().max(PLAN_LIMITS.status).optional(),
|
|
33
|
+
// Who sets a step that names no `checks` of its own.
|
|
34
|
+
checks: PlanChecksSchema
|
|
35
|
+
});
|
|
36
|
+
export const PlanStepSchema = z.object({
|
|
37
|
+
type: z.literal('step'),
|
|
38
|
+
id: PlanItemIdSchema,
|
|
39
|
+
title: TitleSchema,
|
|
40
|
+
description: DescriptionSchema.optional(),
|
|
41
|
+
checks: PlanChecksSchema.optional(),
|
|
42
|
+
// Only on a step without sub-steps; a parent derives its state from its children.
|
|
43
|
+
state: PlanStepStateSchema.optional(),
|
|
44
|
+
// Who set the state last and when, filled by the host and never by an agent.
|
|
45
|
+
by: PlanActorSchema.optional(),
|
|
46
|
+
at: z.string().optional(),
|
|
47
|
+
note: NoteSchema.optional(),
|
|
48
|
+
// A person lifted the lock, so the step is `anyone` from then on and an agent cannot lock it again.
|
|
49
|
+
unlocked: z.boolean().optional(),
|
|
50
|
+
get steps() {
|
|
51
|
+
return z.array(PlanStepSchema).optional();
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
export const PlanTextSchema = z.object({
|
|
55
|
+
type: z.literal('text'),
|
|
56
|
+
id: PlanItemIdSchema,
|
|
57
|
+
title: TitleSchema,
|
|
58
|
+
description: DescriptionSchema.optional()
|
|
59
|
+
});
|
|
60
|
+
// Sections do not nest: they only stand at the top of a plan.
|
|
61
|
+
export const PlanSectionSchema = z.object({
|
|
62
|
+
type: z.literal('section'),
|
|
63
|
+
id: PlanItemIdSchema,
|
|
64
|
+
title: TitleSchema,
|
|
65
|
+
description: DescriptionSchema.optional(),
|
|
66
|
+
items: z.array(z.discriminatedUnion('type', [PlanTextSchema, PlanStepSchema]))
|
|
67
|
+
});
|
|
68
|
+
export const PlanItemSchema = z.discriminatedUnion('type', [PlanSectionSchema, PlanTextSchema, PlanStepSchema]);
|
|
69
|
+
export const PlanSchema = z.object({
|
|
70
|
+
id: PlanIdSchema,
|
|
71
|
+
// Goes up by one on every applied batch of operations.
|
|
72
|
+
rev: z.number().int().nonnegative(),
|
|
73
|
+
createdAt: z.string(),
|
|
74
|
+
meta: PlanMetaSchema,
|
|
75
|
+
items: z.array(PlanItemSchema)
|
|
76
|
+
});
|
|
77
|
+
/*
|
|
78
|
+
* Operations name items by id and apply to the latest rev, without a base rev: a person's click and
|
|
79
|
+
* an agent's update never conflict, and an operation whose id is gone is refused instead.
|
|
80
|
+
*/
|
|
81
|
+
export const PlanSetOpSchema = z.object({
|
|
82
|
+
op: z.literal('set'),
|
|
83
|
+
ids: z.array(PlanItemIdSchema).min(1),
|
|
84
|
+
state: PlanStepStateSchema,
|
|
85
|
+
note: NoteSchema.optional(),
|
|
86
|
+
// Agent only: the step that becomes active in the same rev, so the work moves on without a gap.
|
|
87
|
+
next: PlanItemIdSchema.optional()
|
|
88
|
+
});
|
|
89
|
+
// An empty text clears the note.
|
|
90
|
+
export const PlanNoteOpSchema = z.object({
|
|
91
|
+
op: z.literal('note'),
|
|
92
|
+
id: PlanItemIdSchema,
|
|
93
|
+
text: NoteSchema
|
|
94
|
+
});
|
|
95
|
+
export const PlanUnlockOpSchema = z.object({
|
|
96
|
+
op: z.literal('unlock'),
|
|
97
|
+
ids: z.union([z.array(PlanItemIdSchema).min(1), z.literal('all')])
|
|
98
|
+
});
|
|
99
|
+
/* Without `under` and `after` an item goes last at the top; with only `after` it goes right after that item, beside it. */
|
|
100
|
+
export const PlanAddOpSchema = z.object({
|
|
101
|
+
op: z.literal('add'),
|
|
102
|
+
type: z.enum(['section', 'text', 'step']),
|
|
103
|
+
// Minted by the host when absent.
|
|
104
|
+
id: PlanItemIdSchema.optional(),
|
|
105
|
+
title: TitleSchema,
|
|
106
|
+
description: DescriptionSchema.optional(),
|
|
107
|
+
checks: PlanChecksSchema.optional(),
|
|
108
|
+
under: PlanItemIdSchema.optional(),
|
|
109
|
+
after: PlanItemIdSchema.optional()
|
|
110
|
+
});
|
|
111
|
+
// An empty description removes it.
|
|
112
|
+
export const PlanEditOpSchema = z.object({
|
|
113
|
+
op: z.literal('edit'),
|
|
114
|
+
id: PlanItemIdSchema,
|
|
115
|
+
title: TitleSchema.optional(),
|
|
116
|
+
description: DescriptionSchema.optional(),
|
|
117
|
+
checks: PlanChecksSchema.optional()
|
|
118
|
+
});
|
|
119
|
+
export const PlanMoveOpSchema = z.object({
|
|
120
|
+
op: z.literal('move'),
|
|
121
|
+
id: PlanItemIdSchema,
|
|
122
|
+
under: PlanItemIdSchema.optional(),
|
|
123
|
+
after: PlanItemIdSchema.optional()
|
|
124
|
+
});
|
|
125
|
+
export const PlanRemoveOpSchema = z.object({
|
|
126
|
+
op: z.literal('remove'),
|
|
127
|
+
id: PlanItemIdSchema
|
|
128
|
+
});
|
|
129
|
+
// An empty summary or status removes it.
|
|
130
|
+
export const PlanMetaOpSchema = z.object({
|
|
131
|
+
op: z.literal('meta'),
|
|
132
|
+
title: TitleSchema.optional(),
|
|
133
|
+
summary: DescriptionSchema.optional(),
|
|
134
|
+
status: z.string().max(PLAN_LIMITS.status).optional(),
|
|
135
|
+
checks: PlanChecksSchema.optional()
|
|
136
|
+
});
|
|
137
|
+
// What a person may send: checking off, a note and lifting a lock. The structure is the agent's.
|
|
138
|
+
export const PlanPersonOpSchema = z.discriminatedUnion('op', [PlanSetOpSchema, PlanNoteOpSchema, PlanUnlockOpSchema]);
|
|
139
|
+
export const PlanOpSchema = z.discriminatedUnion('op', [
|
|
140
|
+
PlanSetOpSchema,
|
|
141
|
+
PlanNoteOpSchema,
|
|
142
|
+
PlanUnlockOpSchema,
|
|
143
|
+
PlanAddOpSchema,
|
|
144
|
+
PlanEditOpSchema,
|
|
145
|
+
PlanMoveOpSchema,
|
|
146
|
+
PlanRemoveOpSchema,
|
|
147
|
+
PlanMetaOpSchema
|
|
148
|
+
]);
|
package/dist/text.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Plan, PlanStepState } from './protocol.ts';
|
|
2
|
+
export declare const PLAN_STATE_MARKERS: Record<PlanStepState, string>;
|
|
3
|
+
export declare const PLAN_LEGEND = "[ ] open, [~] active, [x] done, [!] failed, [-] skipped, [?] blocked, [w] warning, [i] info";
|
|
4
|
+
export interface PlanTextOptions {
|
|
5
|
+
others?: readonly Plan[];
|
|
6
|
+
formatTime?: (at: string) => string;
|
|
7
|
+
}
|
|
8
|
+
export declare function progressText(plan: Pick<Plan, 'meta' | 'items'>): string;
|
|
9
|
+
export declare function renderPlanText(plan: Plan, options?: PlanTextOptions): string;
|
package/dist/text.js
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { effectiveChecks, isParentStep, leafSteps, planProgress, stepState } from './tree.js';
|
|
2
|
+
export const PLAN_STATE_MARKERS = {
|
|
3
|
+
open: '[ ]',
|
|
4
|
+
active: '[~]',
|
|
5
|
+
done: '[x]',
|
|
6
|
+
failed: '[!]',
|
|
7
|
+
skipped: '[-]',
|
|
8
|
+
blocked: '[?]',
|
|
9
|
+
warning: '[w]',
|
|
10
|
+
info: '[i]'
|
|
11
|
+
};
|
|
12
|
+
export const PLAN_LEGEND = '[ ] open, [~] active, [x] done, [!] failed, [-] skipped, [?] blocked, [w] warning, [i] info';
|
|
13
|
+
function localTime(at) {
|
|
14
|
+
const date = new Date(at);
|
|
15
|
+
if (Number.isNaN(date.getTime())) {
|
|
16
|
+
return '';
|
|
17
|
+
}
|
|
18
|
+
return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`;
|
|
19
|
+
}
|
|
20
|
+
function oneLine(text) {
|
|
21
|
+
return text.replace(/\s*\n\s*/g, ' ');
|
|
22
|
+
}
|
|
23
|
+
function descriptionLines(description, indent) {
|
|
24
|
+
return description ? [`${indent}${oneLine(description)}`] : [];
|
|
25
|
+
}
|
|
26
|
+
function counter(progress) {
|
|
27
|
+
return `${progress.finished}/${progress.total}`;
|
|
28
|
+
}
|
|
29
|
+
export function progressText(plan) {
|
|
30
|
+
const progress = planProgress(plan.items);
|
|
31
|
+
const extra = (count, label) => (count > 0 ? [`${count} ${label}`] : []);
|
|
32
|
+
const warnings = progress.warning === 1 ? 'warning' : 'warnings';
|
|
33
|
+
if (plan.meta.kind === 'test') {
|
|
34
|
+
return [
|
|
35
|
+
`${progress.finished} of ${progress.total} run`,
|
|
36
|
+
`${progress.done} passed`,
|
|
37
|
+
...extra(progress.warning, warnings),
|
|
38
|
+
...extra(progress.info, 'info'),
|
|
39
|
+
...extra(progress.failed, 'failed'),
|
|
40
|
+
...extra(progress.skipped, 'skipped'),
|
|
41
|
+
...extra(progress.blocked, 'blocked')
|
|
42
|
+
].join(', ');
|
|
43
|
+
}
|
|
44
|
+
// In a steps plan a warning or info is still done; the extras say which of the done steps to read.
|
|
45
|
+
return [
|
|
46
|
+
`${progress.done + progress.warning + progress.info} of ${progress.total} done`,
|
|
47
|
+
...extra(progress.warning, `with ${progress.warning === 1 ? 'a warning' : 'warnings'}`),
|
|
48
|
+
...extra(progress.info, 'with info'),
|
|
49
|
+
...extra(progress.failed, 'failed'),
|
|
50
|
+
...extra(progress.skipped, 'skipped'),
|
|
51
|
+
...extra(progress.blocked, 'blocked')
|
|
52
|
+
].join(', ');
|
|
53
|
+
}
|
|
54
|
+
function sentences(parts) {
|
|
55
|
+
return parts.map((part, index) => (index < parts.length - 1 && !/[.!?]$/.test(part) ? `${part}.` : part)).join(' ');
|
|
56
|
+
}
|
|
57
|
+
/*
|
|
58
|
+
* The compact text `plan read` prints for an agent: one line per item with its id in brackets, so
|
|
59
|
+
* the ids survive a compacted conversation, and a step's or a section's description on the line
|
|
60
|
+
* under it, the way the Markdown of a plan has it. The markers are `PLAN_LEGEND`.
|
|
61
|
+
*/
|
|
62
|
+
export function renderPlanText(plan, options = {}) {
|
|
63
|
+
const formatTime = options.formatTime ?? localTime;
|
|
64
|
+
const lines = [`Plan "${plan.meta.title}" (${plan.id}, ${plan.meta.kind}, rev ${plan.rev}): ${progressText(plan)}`];
|
|
65
|
+
const second = [];
|
|
66
|
+
if (plan.meta.summary) {
|
|
67
|
+
second.push(`Summary: ${oneLine(plan.meta.summary)}`);
|
|
68
|
+
}
|
|
69
|
+
if (plan.meta.status) {
|
|
70
|
+
second.push(`Status: ${plan.meta.status}`);
|
|
71
|
+
}
|
|
72
|
+
const active = leafSteps(plan.items).filter((step) => step.state === 'active');
|
|
73
|
+
if (active.length > 0) {
|
|
74
|
+
second.push(`Now: ${active.map((step) => `"${step.title}" [${step.id}]`).join(', ')}`);
|
|
75
|
+
}
|
|
76
|
+
if (options.others && options.others.length > 0) {
|
|
77
|
+
const others = options.others.map((other) => `${other.id} "${other.meta.title}" (${other.meta.kind}, ${counter(planProgress(other.items))})`);
|
|
78
|
+
second.push(`Also in this chat: ${others.join(', ')}`);
|
|
79
|
+
}
|
|
80
|
+
if (second.length > 0) {
|
|
81
|
+
lines.push(sentences(second));
|
|
82
|
+
}
|
|
83
|
+
const stepLine = (step, number, indent) => {
|
|
84
|
+
let line = `${indent}${PLAN_STATE_MARKERS[stepState(step)]} ${number} ${step.title} [${step.id}]`;
|
|
85
|
+
if (isParentStep(step)) {
|
|
86
|
+
line += ` ${counter(planProgress(step.steps))}`;
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
const notes = [];
|
|
90
|
+
const checks = effectiveChecks(plan, step);
|
|
91
|
+
if (checks !== 'anyone') {
|
|
92
|
+
notes.push(`${checks}-only`);
|
|
93
|
+
}
|
|
94
|
+
else if (step.unlocked) {
|
|
95
|
+
notes.push('unlocked');
|
|
96
|
+
}
|
|
97
|
+
if (step.by === 'person' && step.state !== undefined) {
|
|
98
|
+
const time = step.at ? formatTime(step.at) : '';
|
|
99
|
+
notes.push(time ? `set by a person ${time}` : 'set by a person');
|
|
100
|
+
}
|
|
101
|
+
if (notes.length > 0) {
|
|
102
|
+
line += ` ${notes.join(', ')}`;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (step.note) {
|
|
106
|
+
line += `: "${oneLine(step.note)}"`;
|
|
107
|
+
}
|
|
108
|
+
lines.push(line, ...descriptionLines(step.description, `${indent} `));
|
|
109
|
+
step.steps?.forEach((child, index) => stepLine(child, `${number}.${index + 1}`, `${indent} `));
|
|
110
|
+
};
|
|
111
|
+
const itemLines = (items, indent) => {
|
|
112
|
+
let number = 0;
|
|
113
|
+
for (const item of items) {
|
|
114
|
+
if (item.type === 'text') {
|
|
115
|
+
lines.push(`${indent}${item.title} [${item.id}]${item.description ? `: ${oneLine(item.description)}` : ''}`);
|
|
116
|
+
}
|
|
117
|
+
else if (item.type === 'step') {
|
|
118
|
+
number++;
|
|
119
|
+
stepLine(item, String(number), indent);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
let loose = [];
|
|
124
|
+
const flushLoose = () => {
|
|
125
|
+
if (loose.length > 0) {
|
|
126
|
+
lines.push('');
|
|
127
|
+
itemLines(loose, '');
|
|
128
|
+
loose = [];
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
for (const item of plan.items) {
|
|
132
|
+
if (item.type !== 'section') {
|
|
133
|
+
loose.push(item);
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
flushLoose();
|
|
137
|
+
const progress = planProgress(item.items);
|
|
138
|
+
lines.push('', `## ${item.title} [${item.id}]${progress.total > 0 ? ` ${counter(progress)}` : ''}`, ...descriptionLines(item.description, ' '));
|
|
139
|
+
itemLines(item.items, ' ');
|
|
140
|
+
}
|
|
141
|
+
flushLoose();
|
|
142
|
+
return lines.join('\n');
|
|
143
|
+
}
|
package/dist/tree.d.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { type Plan, type PlanChecks, type PlanItem, type PlanSection, type PlanStep, type PlanStepState } from './protocol.ts';
|
|
2
|
+
export type PlanRefusalCode = 'person-only' | 'set-by-person' | 'unlocked-by-person' | 'step-locked' | 'plan-missing-item' | 'plan-not-found' | 'op-not-allowed' | 'plan-not-a-step' | 'plan-parent-state' | 'plan-bad-position' | 'duplicate-id' | 'plan-too-deep' | 'plan-too-large' | 'plan-invalid' | 'too-many-plans';
|
|
3
|
+
export interface PlanRefusal {
|
|
4
|
+
ok: false;
|
|
5
|
+
code: PlanRefusalCode;
|
|
6
|
+
message: string;
|
|
7
|
+
}
|
|
8
|
+
export declare function refuse(code: PlanRefusalCode, message: string): PlanRefusal;
|
|
9
|
+
export interface PlanLocation {
|
|
10
|
+
item: PlanItem;
|
|
11
|
+
siblings: PlanItem[];
|
|
12
|
+
index: number;
|
|
13
|
+
parent: PlanSection | PlanStep | null;
|
|
14
|
+
depth: number;
|
|
15
|
+
}
|
|
16
|
+
export declare function locateItem(plan: Pick<Plan, 'items'>, id: string): PlanLocation | null;
|
|
17
|
+
export declare function findItem(plan: Pick<Plan, 'items'>, id: string): PlanItem | null;
|
|
18
|
+
export declare function allItems(items: readonly PlanItem[]): PlanItem[];
|
|
19
|
+
export declare function allSteps(items: readonly PlanItem[]): PlanStep[];
|
|
20
|
+
export declare function isParentStep(step: PlanStep): boolean;
|
|
21
|
+
export declare function effectiveChecks(plan: Pick<Plan, 'meta'>, step: PlanStep): PlanChecks;
|
|
22
|
+
export declare function isFinishedOutcome(state: PlanStepState): boolean;
|
|
23
|
+
export declare function deriveState(states: readonly PlanStepState[]): PlanStepState;
|
|
24
|
+
export declare function stepState(step: PlanStep): PlanStepState;
|
|
25
|
+
export interface PlanProgress {
|
|
26
|
+
total: number;
|
|
27
|
+
open: number;
|
|
28
|
+
active: number;
|
|
29
|
+
done: number;
|
|
30
|
+
failed: number;
|
|
31
|
+
skipped: number;
|
|
32
|
+
blocked: number;
|
|
33
|
+
warning: number;
|
|
34
|
+
info: number;
|
|
35
|
+
finished: number;
|
|
36
|
+
}
|
|
37
|
+
export declare function planProgress(items: readonly PlanItem[]): PlanProgress;
|
|
38
|
+
export declare function leafSteps(items: readonly PlanItem[]): PlanStep[];
|
|
39
|
+
export declare function activeStepIds(plan: Pick<Plan, 'items'>): string[];
|
|
40
|
+
export declare function holdsPersonState(item: PlanItem): boolean;
|
|
41
|
+
export declare function structureProblem(plan: Pick<Plan, 'items'>): PlanRefusal | null;
|
package/dist/tree.js
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { PLAN_LIMITS } from './protocol.js';
|
|
2
|
+
export function refuse(code, message) {
|
|
3
|
+
return { ok: false, code, message };
|
|
4
|
+
}
|
|
5
|
+
function childrenOf(item) {
|
|
6
|
+
if (item.type === 'section') {
|
|
7
|
+
return item.items;
|
|
8
|
+
}
|
|
9
|
+
if (item.type === 'step') {
|
|
10
|
+
return item.steps ?? [];
|
|
11
|
+
}
|
|
12
|
+
return [];
|
|
13
|
+
}
|
|
14
|
+
export function locateItem(plan, id) {
|
|
15
|
+
const search = (siblings, parent, depth) => {
|
|
16
|
+
for (let index = 0; index < siblings.length; index++) {
|
|
17
|
+
const item = siblings[index];
|
|
18
|
+
const itemDepth = item.type === 'step' ? depth + 1 : 0;
|
|
19
|
+
if (item.id === id) {
|
|
20
|
+
return { item, siblings, index, parent, depth: itemDepth };
|
|
21
|
+
}
|
|
22
|
+
const found = search(childrenOf(item), item.type === 'text' ? null : item, item.type === 'step' ? itemDepth : depth);
|
|
23
|
+
if (found) {
|
|
24
|
+
return found;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return null;
|
|
28
|
+
};
|
|
29
|
+
return search(plan.items, null, 0);
|
|
30
|
+
}
|
|
31
|
+
export function findItem(plan, id) {
|
|
32
|
+
return locateItem(plan, id)?.item ?? null;
|
|
33
|
+
}
|
|
34
|
+
/* Every item in document order, parents before their children. */
|
|
35
|
+
export function allItems(items) {
|
|
36
|
+
return items.flatMap((item) => [item, ...allItems(childrenOf(item))]);
|
|
37
|
+
}
|
|
38
|
+
export function allSteps(items) {
|
|
39
|
+
return allItems(items).filter((item) => item.type === 'step');
|
|
40
|
+
}
|
|
41
|
+
export function isParentStep(step) {
|
|
42
|
+
return (step.steps?.length ?? 0) > 0;
|
|
43
|
+
}
|
|
44
|
+
/* Unlocked wins over everything: a person lifted the lock and no one puts it back. */
|
|
45
|
+
export function effectiveChecks(plan, step) {
|
|
46
|
+
return step.unlocked ? 'anyone' : (step.checks ?? plan.meta.checks);
|
|
47
|
+
}
|
|
48
|
+
export function isFinishedOutcome(state) {
|
|
49
|
+
return state === 'done' || state === 'skipped' || state === 'warning' || state === 'info';
|
|
50
|
+
}
|
|
51
|
+
/* A warning bubbles up so a parent does not look clean; info is only worth reading on the step itself. */
|
|
52
|
+
export function deriveState(states) {
|
|
53
|
+
if (states.includes('failed')) {
|
|
54
|
+
return 'failed';
|
|
55
|
+
}
|
|
56
|
+
if (states.includes('blocked')) {
|
|
57
|
+
return 'blocked';
|
|
58
|
+
}
|
|
59
|
+
if (states.every(isFinishedOutcome)) {
|
|
60
|
+
return states.includes('warning') ? 'warning' : 'done';
|
|
61
|
+
}
|
|
62
|
+
// A skipped step did not run, so next to open steps alone it moves nothing forward.
|
|
63
|
+
if (states.some((state) => state === 'active' || state === 'done' || state === 'warning' || state === 'info')) {
|
|
64
|
+
return 'active';
|
|
65
|
+
}
|
|
66
|
+
return 'open';
|
|
67
|
+
}
|
|
68
|
+
/* A leaf's own state, or for a parent the state its children add up to. */
|
|
69
|
+
export function stepState(step) {
|
|
70
|
+
return isParentStep(step) ? deriveState(step.steps.map(stepState)) : (step.state ?? 'open');
|
|
71
|
+
}
|
|
72
|
+
export function planProgress(items) {
|
|
73
|
+
const progress = { total: 0, open: 0, active: 0, done: 0, failed: 0, skipped: 0, blocked: 0, warning: 0, info: 0, finished: 0 };
|
|
74
|
+
for (const step of leafSteps(items)) {
|
|
75
|
+
const state = step.state ?? 'open';
|
|
76
|
+
progress.total++;
|
|
77
|
+
progress[state]++;
|
|
78
|
+
if (state === 'failed' || isFinishedOutcome(state)) {
|
|
79
|
+
progress.finished++;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return progress;
|
|
83
|
+
}
|
|
84
|
+
/*
|
|
85
|
+
* The steps that carry a state, in document order. A step with steps under it has no state of its
|
|
86
|
+
* own: it is a heading, and what it says about progress is whatever its leaves say.
|
|
87
|
+
*/
|
|
88
|
+
export function leafSteps(items) {
|
|
89
|
+
return allSteps(items).filter((step) => !isParentStep(step));
|
|
90
|
+
}
|
|
91
|
+
export function activeStepIds(plan) {
|
|
92
|
+
return leafSteps(plan.items)
|
|
93
|
+
.filter((step) => step.state === 'active')
|
|
94
|
+
.map((step) => step.id);
|
|
95
|
+
}
|
|
96
|
+
/* True when the item, or any step under it, carries a state a person set. */
|
|
97
|
+
export function holdsPersonState(item) {
|
|
98
|
+
return allItems([item]).some((entry) => entry.type === 'step' && !isParentStep(entry) && entry.by === 'person' && entry.state !== undefined);
|
|
99
|
+
}
|
|
100
|
+
/* The rules of a plan the schema cannot say, because they need the whole tree. */
|
|
101
|
+
export function structureProblem(plan) {
|
|
102
|
+
const ids = new Set();
|
|
103
|
+
let count = 0;
|
|
104
|
+
const walk = (items, depth, inside) => {
|
|
105
|
+
for (const item of items) {
|
|
106
|
+
count++;
|
|
107
|
+
if (count > PLAN_LIMITS.items) {
|
|
108
|
+
return refuse('plan-too-large', `A plan holds at most ${PLAN_LIMITS.items} items`);
|
|
109
|
+
}
|
|
110
|
+
if (ids.has(item.id)) {
|
|
111
|
+
return refuse('duplicate-id', `Two items share the id "${item.id}"`);
|
|
112
|
+
}
|
|
113
|
+
ids.add(item.id);
|
|
114
|
+
if (item.type === 'section' && inside !== 'top') {
|
|
115
|
+
return refuse('plan-bad-position', `The section "${item.id}" is not at the top of the plan; sections do not nest`);
|
|
116
|
+
}
|
|
117
|
+
if (item.type === 'text' && inside === 'step') {
|
|
118
|
+
return refuse('plan-bad-position', `The text block "${item.id}" is under a step; a step only holds steps`);
|
|
119
|
+
}
|
|
120
|
+
if (item.type === 'step') {
|
|
121
|
+
if (depth + 1 > PLAN_LIMITS.depth) {
|
|
122
|
+
return refuse('plan-too-deep', `The step "${item.id}" is ${depth + 1} levels deep; steps go at most ${PLAN_LIMITS.depth} levels deep`);
|
|
123
|
+
}
|
|
124
|
+
if (isParentStep(item) && (item.state !== undefined || item.by !== undefined || item.at !== undefined)) {
|
|
125
|
+
return refuse('plan-parent-state', `The step "${item.id}" has sub-steps, so its state follows from them`);
|
|
126
|
+
}
|
|
127
|
+
const problem = walk(item.steps ?? [], depth + 1, 'step');
|
|
128
|
+
if (problem) {
|
|
129
|
+
return problem;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (item.type === 'section') {
|
|
133
|
+
const problem = walk(item.items, depth, 'section');
|
|
134
|
+
if (problem) {
|
|
135
|
+
return problem;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return null;
|
|
140
|
+
};
|
|
141
|
+
return walk(plan.items, 0, 'top');
|
|
142
|
+
}
|
package/package.json
CHANGED
|
@@ -1,11 +1,49 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adecore/plan",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.17.0-beta.2",
|
|
4
|
+
"description": "Plan schemas, atomic tree operations, actor permissions and Markdown.",
|
|
5
5
|
"license": "FSL-1.1-MIT",
|
|
6
|
+
"author": "Bas Milius",
|
|
6
7
|
"repository": {
|
|
7
8
|
"type": "git",
|
|
8
9
|
"url": "git+https://github.com/basmilius/adecore.git",
|
|
9
10
|
"directory": "packages/plan"
|
|
11
|
+
},
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/basmilius/adecore/issues"
|
|
14
|
+
},
|
|
15
|
+
"homepage": "https://adecore.dev/plan/",
|
|
16
|
+
"type": "module",
|
|
17
|
+
"sideEffects": false,
|
|
18
|
+
"files": [
|
|
19
|
+
"dist",
|
|
20
|
+
"src",
|
|
21
|
+
"!src/**/*.test.ts",
|
|
22
|
+
"!src/fixtures"
|
|
23
|
+
],
|
|
24
|
+
"exports": {
|
|
25
|
+
".": {
|
|
26
|
+
"source": "./src/index.ts",
|
|
27
|
+
"types": "./dist/index.d.ts",
|
|
28
|
+
"default": "./dist/index.js"
|
|
29
|
+
},
|
|
30
|
+
"./protocol": {
|
|
31
|
+
"source": "./src/protocol.ts",
|
|
32
|
+
"types": "./dist/protocol.d.ts",
|
|
33
|
+
"default": "./dist/protocol.js"
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
"scripts": {
|
|
37
|
+
"build": "bun scripts/build.ts",
|
|
38
|
+
"typecheck": "tsc --noEmit",
|
|
39
|
+
"test": "bun test --conditions=source"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@types/bun": "^1.4.2",
|
|
43
|
+
"@types/node": "^24.13.6",
|
|
44
|
+
"typescript": "^7.0.2"
|
|
45
|
+
},
|
|
46
|
+
"dependencies": {
|
|
47
|
+
"zod": "^4.6.5"
|
|
10
48
|
}
|
|
11
49
|
}
|