mainflow 0.6.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.
@@ -0,0 +1,511 @@
1
+ import { z } from "zod";
2
+ //#region ../schema/dist/index.js
3
+ const FORMAT_VERSION = 2;
4
+ const ID_PATTERNS = {
5
+ mainline: /^m\d{3}$/,
6
+ task: /^t\d{3}$/,
7
+ checklistItem: /^c\d{3}$/,
8
+ criterion: /^r\d{3}$/,
9
+ decision: /^d\d{3}$/,
10
+ action: /^a\d{3}$/,
11
+ issue: /^i\d{3}$/,
12
+ finding: /^f\d{3}$/
13
+ };
14
+ const CHECKLIST_REF = /^m\d{3}\/t\d{3}#c\d{3}$/;
15
+ const SLUG = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
16
+ const DATE = /^\d{4}-\d{2}-\d{2}$/;
17
+ const DATETIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/;
18
+ /** A hub name: kebab-case, also the first label of {hub}.mainflow.localhost and the prefix of a cross-hub reference. */
19
+ const HUB_NAME = SLUG;
20
+ /** Prefixes the web interface already uses in its own references, so no registered hub may take them. */
21
+ const RESERVED_HUB_NAMES = ["file", "session"];
22
+ const REF_FORMS = [
23
+ {
24
+ kind: "mainline",
25
+ pattern: /^(m\d{3})$/,
26
+ example: "m001"
27
+ },
28
+ {
29
+ kind: "task",
30
+ pattern: /^(m\d{3})\/(t\d{3})$/,
31
+ example: "m001/t002"
32
+ },
33
+ {
34
+ kind: "checklistItem",
35
+ pattern: /^(m\d{3})\/(t\d{3})#(c\d{3})$/,
36
+ example: "m001/t002#c003"
37
+ },
38
+ {
39
+ kind: "criterion",
40
+ pattern: /^(m\d{3})#(r\d{3})$/,
41
+ example: "m001#r001"
42
+ },
43
+ {
44
+ kind: "decision",
45
+ pattern: /^(m\d{3})\/(d\d{3})$/,
46
+ example: "m001/d001"
47
+ },
48
+ {
49
+ kind: "issue",
50
+ pattern: /^(i\d{3})$/,
51
+ example: "i001"
52
+ },
53
+ {
54
+ kind: "finding",
55
+ pattern: /^(f\d{3})$/,
56
+ example: "f001"
57
+ }
58
+ ];
59
+ /** The forms a reference can take, for error messages. */
60
+ const REF_EXAMPLES = `${REF_FORMS.map((f) => f.example).join(", ")}, or any of them after another hub's name, like other-hub:m001`;
61
+ /** Split a `{hub}:` prefix off a reference; `hub` is undefined when there is none. */
62
+ function splitHub(input) {
63
+ const colon = input.indexOf(":");
64
+ if (colon < 0) return { local: input };
65
+ const hub = input.slice(0, colon);
66
+ return HUB_NAME.test(hub) ? {
67
+ hub,
68
+ local: input.slice(colon + 1)
69
+ } : {
70
+ hub: "",
71
+ local: input
72
+ };
73
+ }
74
+ /** Parse a reference string, or return null when it matches none of the forms. */
75
+ function parseRef(input) {
76
+ const { hub, local } = splitHub(input);
77
+ if (hub === "") return null;
78
+ const parsed = parseLocal(local);
79
+ return parsed && hub !== void 0 ? {
80
+ ...parsed,
81
+ hub
82
+ } : parsed;
83
+ }
84
+ function parseLocal(input) {
85
+ for (const form of REF_FORMS) {
86
+ const m = form.pattern.exec(input);
87
+ if (!m) continue;
88
+ const [, a = "", b = "", c = ""] = m;
89
+ switch (form.kind) {
90
+ case "mainline": return {
91
+ kind: "mainline",
92
+ mainline: a
93
+ };
94
+ case "task": return {
95
+ kind: "task",
96
+ mainline: a,
97
+ task: b
98
+ };
99
+ case "checklistItem": return {
100
+ kind: "checklistItem",
101
+ mainline: a,
102
+ task: b,
103
+ item: c
104
+ };
105
+ case "criterion": return {
106
+ kind: "criterion",
107
+ mainline: a,
108
+ criterion: b
109
+ };
110
+ case "decision": return {
111
+ kind: "decision",
112
+ mainline: a,
113
+ decision: b
114
+ };
115
+ case "issue": return {
116
+ kind: "issue",
117
+ issue: a
118
+ };
119
+ case "finding": return {
120
+ kind: "finding",
121
+ finding: a
122
+ };
123
+ }
124
+ }
125
+ return null;
126
+ }
127
+ /** The mainline a reference sits in, or undefined for hub-level issues and findings. */
128
+ const refMainline = (ref) => "mainline" in ref ? ref.mainline : void 0;
129
+ /** The example reference for a kind, used in error messages. */
130
+ const refExample = (kind) => REF_FORMS.find((f) => f.kind === kind)?.example ?? "";
131
+ const id = (kind) => z.string().regex(ID_PATTERNS[kind]);
132
+ const date = z.string().regex(DATE);
133
+ const datetime = z.string().regex(DATETIME);
134
+ /** A day, or a day with the time when the moment matters (who decided what, and when). */
135
+ const dateOrTime = z.string().regex(/^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2})?$/);
136
+ const text = z.string().min(1);
137
+ /** Any reference parseRef understands, in this hub or, with a `{hub}:` prefix, in another one. */
138
+ const anyRef = z.string().refine((s) => parseRef(s) !== null, `expected a reference like ${REF_EXAMPLES}`);
139
+ const MAINLINE_STATUS = [
140
+ "planning",
141
+ "active",
142
+ "paused",
143
+ "done",
144
+ "dropped"
145
+ ];
146
+ const WORK_STATUS = [
147
+ "todo",
148
+ "doing",
149
+ "blocked",
150
+ "done",
151
+ "dropped"
152
+ ];
153
+ const DECISION_STATUS = [
154
+ "proposed",
155
+ "accepted",
156
+ "superseded"
157
+ ];
158
+ const DECIDERS = [
159
+ "user",
160
+ "ai",
161
+ "relay"
162
+ ];
163
+ const MERGE_STATES = [
164
+ "open",
165
+ "merged",
166
+ "abandoned"
167
+ ];
168
+ const ISSUE_STATUS = [
169
+ "open",
170
+ "planned",
171
+ "closed"
172
+ ];
173
+ const FINDING_STATUS = ["valid", "obsolete"];
174
+ const SESSION_STATUS = [
175
+ "active",
176
+ "paused",
177
+ "ended",
178
+ "handed-off"
179
+ ];
180
+ /** Statuses after which an entity is closed and carries closedAt. */
181
+ const CLOSED_STATUS = {
182
+ mainline: ["done", "dropped"],
183
+ task: ["done", "dropped"],
184
+ decision: ["superseded"],
185
+ issue: ["closed"],
186
+ finding: ["obsolete"]
187
+ };
188
+ /** Default categories, aligned with Conventional Commits types; a hub adds its own in config.yaml. */
189
+ const DEFAULT_CATEGORIES = [
190
+ "feature",
191
+ "fix",
192
+ "refactor",
193
+ "docs",
194
+ "research",
195
+ "ops",
196
+ "chore"
197
+ ];
198
+ /** P0 blocks other work, P1 must land this round, P2 should be done (the default), P3 when there is time. */
199
+ const PRIORITIES = [
200
+ "P0",
201
+ "P1",
202
+ "P2",
203
+ "P3"
204
+ ];
205
+ const MAINLINE_SECTIONS = [
206
+ "概述",
207
+ "背景與前置",
208
+ "範圍與不做",
209
+ "限制"
210
+ ];
211
+ const TASK_SECTIONS = [
212
+ "目標",
213
+ "背景",
214
+ "做法",
215
+ "完成定義",
216
+ "不做",
217
+ "紀錄",
218
+ "接手須知"
219
+ ];
220
+ /** Task sections that must not stay empty while the task is open. */
221
+ const TASK_REQUIRED_SECTIONS = ["目標", "完成定義"];
222
+ const DECISION_SECTIONS = [
223
+ "背景",
224
+ "選項",
225
+ "建議",
226
+ "決定",
227
+ "理由"
228
+ ];
229
+ const ISSUE_SECTIONS = [
230
+ "背景",
231
+ "要做的事",
232
+ "完成條件"
233
+ ];
234
+ const FINDING_SECTIONS = [
235
+ "發現",
236
+ "影響",
237
+ "做法"
238
+ ];
239
+ const configSchema = z.object({
240
+ /** Also the first label of the hub's host name, {hub}.mainflow.localhost, so kebab-case */
241
+ hub: z.string().regex(HUB_NAME, "hub name must be kebab-case, e.g. carlos-space"),
242
+ formatVersion: z.number().int().positive(),
243
+ repos: z.record(z.string(), z.string()),
244
+ /** Other hubs this hub's references point into: hub name to its root, relative to this hub's root */
245
+ hubs: z.record(z.string().regex(HUB_NAME, "a hub name must be kebab-case").refine((n) => !RESERVED_HUB_NAMES.includes(n), `${RESERVED_HUB_NAMES.join(" and ")} are reserved`), z.string()).optional(),
246
+ /** Categories this hub uses on top of DEFAULT_CATEGORIES */
247
+ categories: z.array(z.string().regex(SLUG, "a category must be kebab-case")).optional(),
248
+ /** The port `mainflow ui` tries first for this hub; when it is taken, the next free one is used and the host name follows */
249
+ ui: z.object({ port: z.number().int().min(1024).max(65535) }).optional()
250
+ });
251
+ const refSchema = z.object({
252
+ title: text,
253
+ url: z.string().url().optional(),
254
+ path: text.optional(),
255
+ date: date.optional()
256
+ }).refine((r) => r.url || r.path, "a reference needs a url or a path");
257
+ /**
258
+ * How a document relates to something outside its own mainline. Only one direction is written: the
259
+ * reverse (what depends on this, what was split from this) is derived by readers, so a relation never
260
+ * has two copies that can disagree.
261
+ */
262
+ const LINK_RELS = [
263
+ "depends-on",
264
+ "relates",
265
+ "split-from"
266
+ ];
267
+ const linkSchema = z.object({
268
+ rel: z.enum(LINK_RELS),
269
+ to: anyRef,
270
+ note: text.optional()
271
+ });
272
+ /**
273
+ * Attributes every document carries (mainline, task, decision, issue, finding) besides id, title, status
274
+ * and priority, which each schema lists first so the front matter reads the same way in every file. `category` is checked
275
+ * against DEFAULT_CATEGORIES plus config.categories when the hub loads, not here, because the schema does
276
+ * not see the config. `updated` is not stored: readers derive it from the file.
277
+ */
278
+ const documentShape = {
279
+ category: z.string().regex(SLUG, "a category must be kebab-case").optional(),
280
+ labels: z.array(text).optional(),
281
+ created: dateOrTime,
282
+ closedAt: dateOrTime.optional(),
283
+ resolution: text.optional(),
284
+ origin: anyRef.optional(),
285
+ refs: z.array(refSchema).optional(),
286
+ /** Relations to documents outside this document's mainline, in this hub or another */
287
+ links: z.array(linkSchema).optional()
288
+ };
289
+ const priority = z.enum(PRIORITIES).optional();
290
+ const evidenceRequired = (item, ctx) => {
291
+ if (item.done && !item.evidence) ctx.addIssue({
292
+ code: "custom",
293
+ path: ["evidence"],
294
+ message: "evidence is required once the item is checked"
295
+ });
296
+ };
297
+ const checkedOrDropped = (item, ctx) => {
298
+ if (item.done && item.dropped) ctx.addIssue({
299
+ code: "custom",
300
+ path: ["dropped"],
301
+ message: "an item is either checked or dropped, not both"
302
+ });
303
+ };
304
+ /** Resolution criteria and checklist items have the same shape; items also name the criteria they serve. */
305
+ const listItemShape = {
306
+ text,
307
+ /** What has to be true to check it; required while the item is still open */
308
+ doneWhen: text.optional(),
309
+ done: z.boolean(),
310
+ evidence: text.nullable().optional(),
311
+ doneAt: date.nullable().optional(),
312
+ /** No longer needed; supersededBy names what replaced it, when something did */
313
+ dropped: z.boolean().optional(),
314
+ supersededBy: anyRef.optional(),
315
+ note: text.optional()
316
+ };
317
+ const criterionSchema = z.object({
318
+ id: id("criterion"),
319
+ ...listItemShape
320
+ }).superRefine(evidenceRequired).superRefine(checkedOrDropped);
321
+ const checklistItemSchema = z.object({
322
+ id: id("checklistItem"),
323
+ ...listItemShape,
324
+ serves: z.array(id("criterion")).optional()
325
+ }).superRefine(evidenceRequired).superRefine(checkedOrDropped);
326
+ const costSchema = z.object({
327
+ date,
328
+ item: text,
329
+ unit: text,
330
+ estimate: z.number().nonnegative().optional(),
331
+ actual: z.number().nonnegative().optional(),
332
+ note: text.optional()
333
+ });
334
+ const mainlineSchema = z.object({
335
+ id: id("mainline"),
336
+ title: text,
337
+ status: z.enum(MAINLINE_STATUS),
338
+ priority,
339
+ ...documentShape,
340
+ due: date.optional(),
341
+ /** Hidden from the default listings; independent of status, a done mainline may stay visible */
342
+ archivedAt: date.optional(),
343
+ criteria: z.array(criterionSchema),
344
+ budget: z.object({
345
+ amount: z.number().positive(),
346
+ unit: text
347
+ }).optional(),
348
+ costs: z.array(costSchema).optional()
349
+ });
350
+ const decisionSchema = z.object({
351
+ id: id("decision"),
352
+ title: text,
353
+ status: z.enum(DECISION_STATUS),
354
+ priority,
355
+ ...documentShape,
356
+ decidedAt: dateOrTime.optional(),
357
+ supersededBy: id("decision").optional(),
358
+ by: z.enum(DECIDERS).optional(),
359
+ /** Tasks and items the decision shapes; while it is proposed, the checklist items among them wait for it */
360
+ affects: z.array(anyRef).optional(),
361
+ /** Whether the choice can be undone cheaply later */
362
+ reversible: z.boolean().optional()
363
+ }).superRefine((d, ctx) => {
364
+ if (d.status === "superseded" && !d.supersededBy) ctx.addIssue({
365
+ code: "custom",
366
+ path: ["supersededBy"],
367
+ message: "a superseded decision must point to its successor"
368
+ });
369
+ });
370
+ const taskSchema = z.object({
371
+ id: id("task"),
372
+ title: text,
373
+ status: z.enum(WORK_STATUS),
374
+ priority,
375
+ ...documentShape,
376
+ kind: z.enum(["phase", "branch"]),
377
+ parent: id("task").nullable().optional(),
378
+ /** Position among the mainline's tasks; lower first */
379
+ order: z.number().int().positive().optional(),
380
+ /** Tasks that must be done before this one starts */
381
+ dependsOn: z.array(id("task")).optional(),
382
+ repo: z.string().min(1).nullable().optional(),
383
+ startedAt: date.optional(),
384
+ checklist: z.array(checklistItemSchema),
385
+ /** Why a blocked task cannot move, what it waits for, and which signal says the wait is over */
386
+ blockedBy: z.object({
387
+ reason: text,
388
+ waitingFor: text.optional(),
389
+ signal: text.optional()
390
+ }).optional(),
391
+ branch: text.optional(),
392
+ worktree: text.optional(),
393
+ base: text.optional(),
394
+ merge: z.enum(MERGE_STATES).optional()
395
+ }).superRefine((t, ctx) => {
396
+ if (t.kind === "branch" && !t.parent) ctx.addIssue({
397
+ code: "custom",
398
+ path: ["parent"],
399
+ message: "a branch task must name the task it branches from"
400
+ });
401
+ if (t.status === "blocked" && !t.blockedBy) ctx.addIssue({
402
+ code: "custom",
403
+ path: ["blockedBy"],
404
+ message: "a blocked task must say what blocks it"
405
+ });
406
+ if (t.dependsOn?.includes(t.id)) ctx.addIssue({
407
+ code: "custom",
408
+ path: ["dependsOn"],
409
+ message: "a task cannot depend on itself"
410
+ });
411
+ });
412
+ const actionSchema = z.object({
413
+ id: id("action"),
414
+ do: z.string().min(1),
415
+ status: z.enum(WORK_STATUS),
416
+ serves: z.array(z.string().regex(CHECKLIST_REF)),
417
+ note: text.optional(),
418
+ /** Set when the action turns doing and done; the transcript trace attributes tool use by these */
419
+ startedAt: datetime.optional(),
420
+ doneAt: datetime.optional()
421
+ });
422
+ const sessionSchema = z.object({
423
+ id: z.string().min(1),
424
+ agent: z.string().min(1),
425
+ status: z.enum(SESSION_STATUS),
426
+ started: datetime,
427
+ delegatedFrom: z.string().min(1).nullable(),
428
+ handoffFrom: z.string().min(1).nullable(),
429
+ cwd: z.string().min(1),
430
+ actions: z.array(actionSchema),
431
+ /** Where the platform keeps the conversation, for tracing and for opening it */
432
+ transcript: text.optional(),
433
+ worktree: text.optional(),
434
+ branch: text.optional(),
435
+ model: text.optional(),
436
+ effort: text.optional(),
437
+ /** Subagents: what the delegating session asked for, nesting depth, and the final reply in brief */
438
+ description: text.optional(),
439
+ depth: z.number().int().nonnegative().optional(),
440
+ summary: text.optional(),
441
+ /** Hidden from the default views; independent of status, an ended session stays visible until archived */
442
+ archivedAt: date.optional()
443
+ });
444
+ /** Front matter of issues/i{NNN}-{slug}.md: follow-ups and waits that must survive the mainline that found them. */
445
+ const issueSchema = z.object({
446
+ id: id("issue"),
447
+ title: text,
448
+ status: z.enum(ISSUE_STATUS),
449
+ priority,
450
+ ...documentShape,
451
+ order: z.number().int().positive().optional(),
452
+ due: date.optional(),
453
+ plannedIn: z.string().regex(/^m\d{3}\/t\d{3}$/).optional(),
454
+ /** Hidden from the default views; independent of status, a closed issue stays visible until archived */
455
+ archivedAt: date.optional()
456
+ }).superRefine((i, ctx) => {
457
+ if (i.status === "planned" && !i.plannedIn) ctx.addIssue({
458
+ code: "custom",
459
+ path: ["plannedIn"],
460
+ message: "a planned issue must name the task it is planned in"
461
+ });
462
+ if (i.status === "closed" && !i.resolution) ctx.addIssue({
463
+ code: "custom",
464
+ path: ["resolution"],
465
+ message: "a closed issue must say what closed it"
466
+ });
467
+ });
468
+ /** Front matter of findings/f{NNN}-{slug}.md: lessons and pitfalls the next task should read before it starts. */
469
+ const findingSchema = z.object({
470
+ id: id("finding"),
471
+ title: text,
472
+ status: z.enum(FINDING_STATUS),
473
+ ...documentShape,
474
+ /** Repositories or paths the lesson applies to, so a task working there can find it */
475
+ appliesTo: z.array(text).optional(),
476
+ /** When to check whether the lesson still holds */
477
+ reviewAfter: date.optional(),
478
+ /** Hidden from the default views; independent of status, an obsolete finding stays visible until archived */
479
+ archivedAt: date.optional()
480
+ });
481
+ /** One line of ops/{YYYY-MM}.log: an external operation, append-only. */
482
+ const opsEntrySchema = z.object({
483
+ at: datetime,
484
+ target: text,
485
+ operation: text,
486
+ result: text,
487
+ ref: anyRef.optional()
488
+ });
489
+ const OPS_SEPARATOR = " | ";
490
+ function formatOpsEntry(entry) {
491
+ return [
492
+ entry.at,
493
+ entry.target,
494
+ entry.operation,
495
+ entry.result,
496
+ ...entry.ref ? [entry.ref] : []
497
+ ].join(OPS_SEPARATOR);
498
+ }
499
+ function parseOpsLine(line) {
500
+ const [at, target, operation, result, ref] = line.split(OPS_SEPARATOR).map((part) => part.trim());
501
+ const parsed = opsEntrySchema.safeParse({
502
+ at,
503
+ target,
504
+ operation,
505
+ result,
506
+ ...ref ? { ref } : {}
507
+ });
508
+ return parsed.success ? parsed.data : null;
509
+ }
510
+ //#endregion
511
+ export { mainlineSchema as A, TASK_SECTIONS as C, findingSchema as D, decisionSchema as E, refMainline as F, sessionSchema as I, taskSchema as L, parseOpsLine as M, parseRef as N, formatOpsEntry as O, refExample as P, TASK_REQUIRED_SECTIONS as S, configSchema as T, PRIORITIES as _, DECISION_STATUS as a, SESSION_STATUS as b, FINDING_STATUS as c, ISSUE_SECTIONS as d, ISSUE_STATUS as f, MERGE_STATES as g, MAINLINE_STATUS as h, DECISION_SECTIONS as i, opsEntrySchema as j, issueSchema as k, FORMAT_VERSION as l, MAINLINE_SECTIONS as m, CLOSED_STATUS as n, DEFAULT_CATEGORIES as o, LINK_RELS as p, DECIDERS as r, FINDING_SECTIONS as s, CHECKLIST_REF as t, ID_PATTERNS as u, REF_EXAMPLES as v, WORK_STATUS as w, SLUG as x, RESERVED_HUB_NAMES as y };