@adecore/plan 0.0.1 → 0.17.0-beta.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/LICENSE +110 -0
- package/README.md +35 -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/src/text.ts
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import type { Plan, PlanItem, PlanStep, PlanStepState } from './protocol.ts';
|
|
2
|
+
import { effectiveChecks, isParentStep, leafSteps, planProgress, stepState, type PlanProgress } from './tree.ts';
|
|
3
|
+
|
|
4
|
+
export const PLAN_STATE_MARKERS: Record<PlanStepState, string> = {
|
|
5
|
+
open: '[ ]',
|
|
6
|
+
active: '[~]',
|
|
7
|
+
done: '[x]',
|
|
8
|
+
failed: '[!]',
|
|
9
|
+
skipped: '[-]',
|
|
10
|
+
blocked: '[?]',
|
|
11
|
+
warning: '[w]',
|
|
12
|
+
info: '[i]'
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export const PLAN_LEGEND = '[ ] open, [~] active, [x] done, [!] failed, [-] skipped, [?] blocked, [w] warning, [i] info';
|
|
16
|
+
|
|
17
|
+
export interface PlanTextOptions {
|
|
18
|
+
/* The chat's other plans, named on the second line. */
|
|
19
|
+
others?: readonly Plan[];
|
|
20
|
+
/* How `at` reads; the default is the local hour and minute. */
|
|
21
|
+
formatTime?: (at: string) => string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function localTime(at: string): string {
|
|
25
|
+
const date = new Date(at);
|
|
26
|
+
if (Number.isNaN(date.getTime())) {
|
|
27
|
+
return '';
|
|
28
|
+
}
|
|
29
|
+
return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function oneLine(text: string): string {
|
|
33
|
+
return text.replace(/\s*\n\s*/g, ' ');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function descriptionLines(description: string | undefined, indent: string): string[] {
|
|
37
|
+
return description ? [`${indent}${oneLine(description)}`] : [];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function counter(progress: PlanProgress): string {
|
|
41
|
+
return `${progress.finished}/${progress.total}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function progressText(plan: Pick<Plan, 'meta' | 'items'>): string {
|
|
45
|
+
const progress = planProgress(plan.items);
|
|
46
|
+
const extra = (count: number, label: string): string[] => (count > 0 ? [`${count} ${label}`] : []);
|
|
47
|
+
const warnings = progress.warning === 1 ? 'warning' : 'warnings';
|
|
48
|
+
if (plan.meta.kind === 'test') {
|
|
49
|
+
return [
|
|
50
|
+
`${progress.finished} of ${progress.total} run`,
|
|
51
|
+
`${progress.done} passed`,
|
|
52
|
+
...extra(progress.warning, warnings),
|
|
53
|
+
...extra(progress.info, 'info'),
|
|
54
|
+
...extra(progress.failed, 'failed'),
|
|
55
|
+
...extra(progress.skipped, 'skipped'),
|
|
56
|
+
...extra(progress.blocked, 'blocked')
|
|
57
|
+
].join(', ');
|
|
58
|
+
}
|
|
59
|
+
// In a steps plan a warning or info is still done; the extras say which of the done steps to read.
|
|
60
|
+
return [
|
|
61
|
+
`${progress.done + progress.warning + progress.info} of ${progress.total} done`,
|
|
62
|
+
...extra(progress.warning, `with ${progress.warning === 1 ? 'a warning' : 'warnings'}`),
|
|
63
|
+
...extra(progress.info, 'with info'),
|
|
64
|
+
...extra(progress.failed, 'failed'),
|
|
65
|
+
...extra(progress.skipped, 'skipped'),
|
|
66
|
+
...extra(progress.blocked, 'blocked')
|
|
67
|
+
].join(', ');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function sentences(parts: string[]): string {
|
|
71
|
+
return parts.map((part, index) => (index < parts.length - 1 && !/[.!?]$/.test(part) ? `${part}.` : part)).join(' ');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/*
|
|
75
|
+
* The compact text `plan read` prints for an agent: one line per item with its id in brackets, so
|
|
76
|
+
* the ids survive a compacted conversation, and a step's or a section's description on the line
|
|
77
|
+
* under it, the way the Markdown of a plan has it. The markers are `PLAN_LEGEND`.
|
|
78
|
+
*/
|
|
79
|
+
export function renderPlanText(plan: Plan, options: PlanTextOptions = {}): string {
|
|
80
|
+
const formatTime = options.formatTime ?? localTime;
|
|
81
|
+
const lines = [`Plan "${plan.meta.title}" (${plan.id}, ${plan.meta.kind}, rev ${plan.rev}): ${progressText(plan)}`];
|
|
82
|
+
const second: string[] = [];
|
|
83
|
+
if (plan.meta.summary) {
|
|
84
|
+
second.push(`Summary: ${oneLine(plan.meta.summary)}`);
|
|
85
|
+
}
|
|
86
|
+
if (plan.meta.status) {
|
|
87
|
+
second.push(`Status: ${plan.meta.status}`);
|
|
88
|
+
}
|
|
89
|
+
const active = leafSteps(plan.items).filter((step) => step.state === 'active');
|
|
90
|
+
if (active.length > 0) {
|
|
91
|
+
second.push(`Now: ${active.map((step) => `"${step.title}" [${step.id}]`).join(', ')}`);
|
|
92
|
+
}
|
|
93
|
+
if (options.others && options.others.length > 0) {
|
|
94
|
+
const others = options.others.map((other) => `${other.id} "${other.meta.title}" (${other.meta.kind}, ${counter(planProgress(other.items))})`);
|
|
95
|
+
second.push(`Also in this chat: ${others.join(', ')}`);
|
|
96
|
+
}
|
|
97
|
+
if (second.length > 0) {
|
|
98
|
+
lines.push(sentences(second));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const stepLine = (step: PlanStep, number: string, indent: string): void => {
|
|
102
|
+
let line = `${indent}${PLAN_STATE_MARKERS[stepState(step)]} ${number} ${step.title} [${step.id}]`;
|
|
103
|
+
if (isParentStep(step)) {
|
|
104
|
+
line += ` ${counter(planProgress(step.steps!))}`;
|
|
105
|
+
} else {
|
|
106
|
+
const notes: string[] = [];
|
|
107
|
+
const checks = effectiveChecks(plan, step);
|
|
108
|
+
if (checks !== 'anyone') {
|
|
109
|
+
notes.push(`${checks}-only`);
|
|
110
|
+
} else if (step.unlocked) {
|
|
111
|
+
notes.push('unlocked');
|
|
112
|
+
}
|
|
113
|
+
if (step.by === 'person' && step.state !== undefined) {
|
|
114
|
+
const time = step.at ? formatTime(step.at) : '';
|
|
115
|
+
notes.push(time ? `set by a person ${time}` : 'set by a person');
|
|
116
|
+
}
|
|
117
|
+
if (notes.length > 0) {
|
|
118
|
+
line += ` ${notes.join(', ')}`;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if (step.note) {
|
|
122
|
+
line += `: "${oneLine(step.note)}"`;
|
|
123
|
+
}
|
|
124
|
+
lines.push(line, ...descriptionLines(step.description, `${indent} `));
|
|
125
|
+
step.steps?.forEach((child, index) => stepLine(child, `${number}.${index + 1}`, `${indent} `));
|
|
126
|
+
};
|
|
127
|
+
const itemLines = (items: readonly PlanItem[], indent: string): void => {
|
|
128
|
+
let number = 0;
|
|
129
|
+
for (const item of items) {
|
|
130
|
+
if (item.type === 'text') {
|
|
131
|
+
lines.push(`${indent}${item.title} [${item.id}]${item.description ? `: ${oneLine(item.description)}` : ''}`);
|
|
132
|
+
} else if (item.type === 'step') {
|
|
133
|
+
number++;
|
|
134
|
+
stepLine(item, String(number), indent);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
let loose: PlanItem[] = [];
|
|
140
|
+
const flushLoose = (): void => {
|
|
141
|
+
if (loose.length > 0) {
|
|
142
|
+
lines.push('');
|
|
143
|
+
itemLines(loose, '');
|
|
144
|
+
loose = [];
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
for (const item of plan.items) {
|
|
148
|
+
if (item.type !== 'section') {
|
|
149
|
+
loose.push(item);
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
flushLoose();
|
|
153
|
+
const progress = planProgress(item.items);
|
|
154
|
+
lines.push('', `## ${item.title} [${item.id}]${progress.total > 0 ? ` ${counter(progress)}` : ''}`, ...descriptionLines(item.description, ' '));
|
|
155
|
+
itemLines(item.items, ' ');
|
|
156
|
+
}
|
|
157
|
+
flushLoose();
|
|
158
|
+
return lines.join('\n');
|
|
159
|
+
}
|
package/src/tree.ts
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { PLAN_LIMITS, type Plan, type PlanChecks, type PlanItem, type PlanSection, type PlanStep, type PlanStepState } from './protocol.ts';
|
|
2
|
+
|
|
3
|
+
export type PlanRefusalCode =
|
|
4
|
+
| 'person-only'
|
|
5
|
+
| 'set-by-person'
|
|
6
|
+
| 'unlocked-by-person'
|
|
7
|
+
| 'step-locked'
|
|
8
|
+
| 'plan-missing-item'
|
|
9
|
+
| 'plan-not-found'
|
|
10
|
+
| 'op-not-allowed'
|
|
11
|
+
| 'plan-not-a-step'
|
|
12
|
+
| 'plan-parent-state'
|
|
13
|
+
| 'plan-bad-position'
|
|
14
|
+
| 'duplicate-id'
|
|
15
|
+
| 'plan-too-deep'
|
|
16
|
+
| 'plan-too-large'
|
|
17
|
+
| 'plan-invalid'
|
|
18
|
+
| 'too-many-plans';
|
|
19
|
+
|
|
20
|
+
export interface PlanRefusal {
|
|
21
|
+
ok: false;
|
|
22
|
+
code: PlanRefusalCode;
|
|
23
|
+
message: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function refuse(code: PlanRefusalCode, message: string): PlanRefusal {
|
|
27
|
+
return { ok: false, code, message };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/* Where an item stands: the array that holds it, so an operation can splice it, and the step or section around it. */
|
|
31
|
+
export interface PlanLocation {
|
|
32
|
+
item: PlanItem;
|
|
33
|
+
siblings: PlanItem[];
|
|
34
|
+
index: number;
|
|
35
|
+
parent: PlanSection | PlanStep | null;
|
|
36
|
+
/* How deep a step is among steps, 1 at the top or in a section; 0 for a section or a text block. */
|
|
37
|
+
depth: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function childrenOf(item: PlanItem): PlanItem[] {
|
|
41
|
+
if (item.type === 'section') {
|
|
42
|
+
return item.items;
|
|
43
|
+
}
|
|
44
|
+
if (item.type === 'step') {
|
|
45
|
+
return item.steps ?? [];
|
|
46
|
+
}
|
|
47
|
+
return [];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function locateItem(plan: Pick<Plan, 'items'>, id: string): PlanLocation | null {
|
|
51
|
+
const search = (siblings: PlanItem[], parent: PlanSection | PlanStep | null, depth: number): PlanLocation | null => {
|
|
52
|
+
for (let index = 0; index < siblings.length; index++) {
|
|
53
|
+
const item = siblings[index]!;
|
|
54
|
+
const itemDepth = item.type === 'step' ? depth + 1 : 0;
|
|
55
|
+
if (item.id === id) {
|
|
56
|
+
return { item, siblings, index, parent, depth: itemDepth };
|
|
57
|
+
}
|
|
58
|
+
const found = search(childrenOf(item), item.type === 'text' ? null : item, item.type === 'step' ? itemDepth : depth);
|
|
59
|
+
if (found) {
|
|
60
|
+
return found;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return null;
|
|
64
|
+
};
|
|
65
|
+
return search(plan.items, null, 0);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function findItem(plan: Pick<Plan, 'items'>, id: string): PlanItem | null {
|
|
69
|
+
return locateItem(plan, id)?.item ?? null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/* Every item in document order, parents before their children. */
|
|
73
|
+
export function allItems(items: readonly PlanItem[]): PlanItem[] {
|
|
74
|
+
return items.flatMap((item) => [item, ...allItems(childrenOf(item))]);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function allSteps(items: readonly PlanItem[]): PlanStep[] {
|
|
78
|
+
return allItems(items).filter((item): item is PlanStep => item.type === 'step');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function isParentStep(step: PlanStep): boolean {
|
|
82
|
+
return (step.steps?.length ?? 0) > 0;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/* Unlocked wins over everything: a person lifted the lock and no one puts it back. */
|
|
86
|
+
export function effectiveChecks(plan: Pick<Plan, 'meta'>, step: PlanStep): PlanChecks {
|
|
87
|
+
return step.unlocked ? 'anyone' : (step.checks ?? plan.meta.checks);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function isFinishedOutcome(state: PlanStepState): boolean {
|
|
91
|
+
return state === 'done' || state === 'skipped' || state === 'warning' || state === 'info';
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/* A warning bubbles up so a parent does not look clean; info is only worth reading on the step itself. */
|
|
95
|
+
export function deriveState(states: readonly PlanStepState[]): PlanStepState {
|
|
96
|
+
if (states.includes('failed')) {
|
|
97
|
+
return 'failed';
|
|
98
|
+
}
|
|
99
|
+
if (states.includes('blocked')) {
|
|
100
|
+
return 'blocked';
|
|
101
|
+
}
|
|
102
|
+
if (states.every(isFinishedOutcome)) {
|
|
103
|
+
return states.includes('warning') ? 'warning' : 'done';
|
|
104
|
+
}
|
|
105
|
+
// A skipped step did not run, so next to open steps alone it moves nothing forward.
|
|
106
|
+
if (states.some((state) => state === 'active' || state === 'done' || state === 'warning' || state === 'info')) {
|
|
107
|
+
return 'active';
|
|
108
|
+
}
|
|
109
|
+
return 'open';
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/* A leaf's own state, or for a parent the state its children add up to. */
|
|
113
|
+
export function stepState(step: PlanStep): PlanStepState {
|
|
114
|
+
return isParentStep(step) ? deriveState(step.steps!.map(stepState)) : (step.state ?? 'open');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export interface PlanProgress {
|
|
118
|
+
/* Leaf steps only: a parent is its children. */
|
|
119
|
+
total: number;
|
|
120
|
+
open: number;
|
|
121
|
+
active: number;
|
|
122
|
+
done: number;
|
|
123
|
+
failed: number;
|
|
124
|
+
skipped: number;
|
|
125
|
+
blocked: number;
|
|
126
|
+
warning: number;
|
|
127
|
+
info: number;
|
|
128
|
+
/* Steps with an outcome: done, failed, skipped, warning or info. */
|
|
129
|
+
finished: number;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function planProgress(items: readonly PlanItem[]): PlanProgress {
|
|
133
|
+
const progress: PlanProgress = { total: 0, open: 0, active: 0, done: 0, failed: 0, skipped: 0, blocked: 0, warning: 0, info: 0, finished: 0 };
|
|
134
|
+
for (const step of leafSteps(items)) {
|
|
135
|
+
const state = step.state ?? 'open';
|
|
136
|
+
progress.total++;
|
|
137
|
+
progress[state]++;
|
|
138
|
+
if (state === 'failed' || isFinishedOutcome(state)) {
|
|
139
|
+
progress.finished++;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return progress;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/*
|
|
146
|
+
* The steps that carry a state, in document order. A step with steps under it has no state of its
|
|
147
|
+
* own: it is a heading, and what it says about progress is whatever its leaves say.
|
|
148
|
+
*/
|
|
149
|
+
export function leafSteps(items: readonly PlanItem[]): PlanStep[] {
|
|
150
|
+
return allSteps(items).filter((step) => !isParentStep(step));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function activeStepIds(plan: Pick<Plan, 'items'>): string[] {
|
|
154
|
+
return leafSteps(plan.items)
|
|
155
|
+
.filter((step) => step.state === 'active')
|
|
156
|
+
.map((step) => step.id);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/* True when the item, or any step under it, carries a state a person set. */
|
|
160
|
+
export function holdsPersonState(item: PlanItem): boolean {
|
|
161
|
+
return allItems([item]).some((entry) => entry.type === 'step' && !isParentStep(entry) && entry.by === 'person' && entry.state !== undefined);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/* The rules of a plan the schema cannot say, because they need the whole tree. */
|
|
165
|
+
export function structureProblem(plan: Pick<Plan, 'items'>): PlanRefusal | null {
|
|
166
|
+
const ids = new Set<string>();
|
|
167
|
+
let count = 0;
|
|
168
|
+
const walk = (items: readonly PlanItem[], depth: number, inside: 'top' | 'section' | 'step'): PlanRefusal | null => {
|
|
169
|
+
for (const item of items) {
|
|
170
|
+
count++;
|
|
171
|
+
if (count > PLAN_LIMITS.items) {
|
|
172
|
+
return refuse('plan-too-large', `A plan holds at most ${PLAN_LIMITS.items} items`);
|
|
173
|
+
}
|
|
174
|
+
if (ids.has(item.id)) {
|
|
175
|
+
return refuse('duplicate-id', `Two items share the id "${item.id}"`);
|
|
176
|
+
}
|
|
177
|
+
ids.add(item.id);
|
|
178
|
+
if (item.type === 'section' && inside !== 'top') {
|
|
179
|
+
return refuse('plan-bad-position', `The section "${item.id}" is not at the top of the plan; sections do not nest`);
|
|
180
|
+
}
|
|
181
|
+
if (item.type === 'text' && inside === 'step') {
|
|
182
|
+
return refuse('plan-bad-position', `The text block "${item.id}" is under a step; a step only holds steps`);
|
|
183
|
+
}
|
|
184
|
+
if (item.type === 'step') {
|
|
185
|
+
if (depth + 1 > PLAN_LIMITS.depth) {
|
|
186
|
+
return refuse('plan-too-deep', `The step "${item.id}" is ${depth + 1} levels deep; steps go at most ${PLAN_LIMITS.depth} levels deep`);
|
|
187
|
+
}
|
|
188
|
+
if (isParentStep(item) && (item.state !== undefined || item.by !== undefined || item.at !== undefined)) {
|
|
189
|
+
return refuse('plan-parent-state', `The step "${item.id}" has sub-steps, so its state follows from them`);
|
|
190
|
+
}
|
|
191
|
+
const problem = walk(item.steps ?? [], depth + 1, 'step');
|
|
192
|
+
if (problem) {
|
|
193
|
+
return problem;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
if (item.type === 'section') {
|
|
197
|
+
const problem = walk(item.items, depth, 'section');
|
|
198
|
+
if (problem) {
|
|
199
|
+
return problem;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return null;
|
|
204
|
+
};
|
|
205
|
+
return walk(plan.items, 0, 'top');
|
|
206
|
+
}
|