@astrosheep/keiyaku 4.5.5 → 4.5.6

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.
@@ -70,30 +70,26 @@ Relations are explicit facts: `needs` orders work, `parent` groups it,
70
70
  ## Batch Create Or Modify
71
71
 
72
72
  Use `task compose -` to create and modify multiple Tasks in one planning
73
- document:
73
+ document. Its input grammar is documented by the Task CLI owner:
74
74
 
75
75
  ```bash
76
76
  keiyaku task compose - <<'EOF'
77
77
  ns=feature
78
- + Parent pri=0
78
+
79
+ + Parent
80
+ as = parent
81
+ pri = 0
82
+ body <<BODY
79
83
  Parent body.
80
- + Child needs=@task/existing
81
- @task/existing pri=1 relates+=@task/other
82
- Replacement body.
84
+ BODY
85
+
86
+ + Child
87
+ parent = ^parent
88
+ needs = @task/existing
83
89
  EOF
84
90
  ```
85
91
 
86
- - Optional first line `ns=<segment/...>` selects the allocation namespace;
87
- `ns=` selects root.
88
- - `+ <title>` creates a Task. `@task/<id>` modifies an existing Task.
89
- - Two spaces of indentation assign the preceding shallower Task as `parent`.
90
- - Inline assignments are `pri=`, `parent=`, `needs=`, `supersedes=`, and
91
- `relates=`. Relation values are comma-separated `@task/...` references.
92
- - `=` replaces or clears a field. `+=` appends only to `needs`, `supersedes`,
93
- or `relates`.
94
- - Prose after a node replaces its body; bare `body=` clears it. Prefix body
95
- lines beginning with `+ `, `@task/`, or `\` with one `\`.
96
-
97
- Compose does not change Task lifecycle state. Each changed Task is admitted
98
- independently; an incomplete result returns a canonical draft for the remaining
99
- batch.
92
+ Use `--plan` to inspect aliases, admission order, and body byte previews without
93
+ writing. Compose does not change Task lifecycle state. Each changed Task is
94
+ admitted independently; an incomplete result returns a reusable draft for the
95
+ remaining batch.
@@ -19,6 +19,7 @@ export type TaskWorldObservation = Readonly<{
19
19
  }>;
20
20
  type TaskInput = Readonly<{
21
21
  world: WorldRoot | null;
22
+ candidate: WorldRoot | null;
22
23
  context: Readonly<{
23
24
  directory: string;
24
25
  boundary: string;
@@ -30,6 +31,7 @@ type TaskInput = Readonly<{
30
31
  export declare function invokeTaskFromEdge(input: Readonly<{
31
32
  parsed: ParsedTaskCommand;
32
33
  world: WorldRoot | null;
34
+ candidate: WorldRoot | null;
33
35
  context: Readonly<{
34
36
  directory: string;
35
37
  boundary: string;
@@ -8,6 +8,7 @@ export async function invokeTaskFromEdge(input) {
8
8
  try {
9
9
  return await invokeTask(input.parsed, {
10
10
  world: input.world,
11
+ candidate: input.candidate,
11
12
  context: input.context,
12
13
  establish: input.establish,
13
14
  readStdin: input.readStdin,
@@ -293,6 +294,8 @@ function missingWorld(command) {
293
294
  });
294
295
  if (isWorldObservation(command))
295
296
  return { kind: "absent" };
297
+ if (command.action === "compose" && command.flags.plan === true)
298
+ return { kind: "absent" };
296
299
  if (command.action === "namespace")
297
300
  return { kind: "accepted", value: [] };
298
301
  if (command.action === "hold" || command.action === "done" || command.action === "drop") {
@@ -338,6 +341,7 @@ async function invokeLocalMutation(tasks, command, input, current) {
338
341
  markdown: await input.readStdin(),
339
342
  namespace: current ?? [],
340
343
  ...(input.actor === undefined ? {} : { actor: input.actor }),
344
+ ...(command.flags.plan === true ? { plan: true } : {}),
341
345
  });
342
346
  default:
343
347
  throw new Error(`task action has no invocation: ${command.action}`);
@@ -345,17 +349,19 @@ async function invokeLocalMutation(tasks, command, input, current) {
345
349
  }
346
350
  function establishesWorld(command) {
347
351
  return (command.action === "add" ||
348
- command.action === "compose" ||
352
+ (command.action === "compose" && command.flags.plan !== true) ||
349
353
  (command.action === "namespace" && command.positionals.length > 0));
350
354
  }
351
355
  function forwardsMutation(command) {
352
356
  return (command.action !== "show" &&
353
357
  command.action !== "tree" &&
354
358
  command.action !== "namespace" &&
359
+ !(command.action === "compose" && command.flags.plan === true) &&
355
360
  !isWorldObservation(command));
356
361
  }
357
362
  export async function invokeTask(command, input) {
358
- const world = input.world ?? (establishesWorld(command) ? await input.establish() : null);
363
+ const planOnly = command.action === "compose" && command.flags.plan === true;
364
+ const world = input.world ?? (planOnly ? input.candidate : establishesWorld(command) ? await input.establish() : null);
359
365
  if (world === null)
360
366
  return missingWorld(command);
361
367
  const tasks = Tasks.of(world);
@@ -133,9 +133,27 @@ task add [--namespace <ns>] [--actor <actor>] -`,
133
133
  compose: {
134
134
  arity: [0, 0],
135
135
  stdin: "compose",
136
- flags: { ...COMMON, actor: "value" },
137
- usage: "task compose [--actor <actor>] -",
138
- purpose: "Admit Task documents independently; partial admission has no cross-file atomicity or rollback.",
136
+ flags: { ...COMMON, actor: "value", plan: "boolean" },
137
+ usage: "task compose [--actor <actor>] [--plan] -",
138
+ purpose: "Plan or admit one explicit Task composition.",
139
+ details: [
140
+ "nodes: + <Title> creates; @task/<id> modifies a pre-existing Task",
141
+ "properties: as = <alias>; pri = 0..3; parent = <ref>|empty",
142
+ "relations: needs|supersedes|relates with =, +=, or -=",
143
+ "references: @task/... is pre-existing; ^alias is new in this document",
144
+ "body: body = clears; body <<TOKEN reads exact content to TOKEN",
145
+ "TOKEN: [A-Z][A-Z0-9_]* with length 3..32",
146
+ "example:",
147
+ " ns=feature",
148
+ " + Parent",
149
+ " as = parent",
150
+ " pri = 1",
151
+ " body <<BODY",
152
+ " Exact body bytes.",
153
+ " BODY",
154
+ " + Child",
155
+ " parent = ^parent",
156
+ ].join("\n"),
139
157
  },
140
158
  };
141
159
  export function isTaskAction(value) {
@@ -318,6 +318,7 @@ async function invokeParsed(invocation, runtime) {
318
318
  return await (await import("./commands/task-invoke.js")).invokeTaskFromEdge({
319
319
  parsed,
320
320
  world,
321
+ candidate: candidateWorld,
321
322
  context: taskContext,
322
323
  establish: coordinates.establishWorld,
323
324
  readStdin: edge.readStdin,
@@ -59,6 +59,9 @@ function projectRefusal(refusal) {
59
59
  if (refusal.kind === "relation-owned-by-other") {
60
60
  return { line: `relation-owned-by-other ${refusal.taskId} ${refusal.related} ${refusal.declaringTask}` };
61
61
  }
62
+ if (refusal.kind === "invalid-composition") {
63
+ return { line: refusal.kind, compositionDiagnostics: refusal.diagnostics };
64
+ }
62
65
  return { line: refusal.kind, diagnostic: refusal.diagnostic };
63
66
  }
64
67
  function appendDiagnostic(lines, diagnostic) {
@@ -73,6 +76,9 @@ function renderFailure(verb, result, columns) {
73
76
  const facts = projectRefusal(result.refusal);
74
77
  lines.push(facts.line);
75
78
  appendDiagnostic(lines, facts.diagnostic);
79
+ for (const item of facts.compositionDiagnostics ?? []) {
80
+ lines.push(`line ${item.line} · ${safeText(item.reason)} · ${safeText(item.token)}`);
81
+ }
76
82
  return lines.join("\n");
77
83
  }
78
84
  function edge(label, ref, mark) {
@@ -256,12 +262,30 @@ function stoppedLines(stopped) {
256
262
  appendDiagnostic(lines, facts.diagnostic);
257
263
  return lines;
258
264
  }
265
+ function aliasLines(aliases) {
266
+ return aliases.map((binding) => `alias ^${binding.alias} ${binding.taskId}`);
267
+ }
268
+ function renderPlan(result) {
269
+ const lines = [
270
+ `compose plan · ${result.admissionOrder.length} documents`,
271
+ ...aliasLines(result.aliases),
272
+ ...result.admissionOrder.map((id, index) => `admit ${index + 1} ${id}`),
273
+ ];
274
+ for (const body of result.bodies) {
275
+ lines.push(`body ${body.taskId} · ${body.bytes} bytes`);
276
+ lines.push(` first ${safeText(body.firstLine)}`);
277
+ lines.push(` last ${safeText(body.lastLine)}`);
278
+ }
279
+ return lines.join("\n");
280
+ }
259
281
  function renderCompose(result, columns) {
260
282
  if (result.kind === "incomplete")
261
283
  return "";
284
+ if (result.kind === "planned")
285
+ return renderPlan(result);
262
286
  if (result.kind !== "accepted")
263
287
  return renderFailure("compose", result, columns);
264
- const lines = [`✓ compose accepted · ${result.documentChanges.length} changed`];
288
+ const lines = [`✓ compose accepted · ${result.documentChanges.length} changed`, ...aliasLines(result.aliases)];
265
289
  composeDiffs(lines, result.documentChanges);
266
290
  return lines.join("\n");
267
291
  }
@@ -0,0 +1,43 @@
1
+ import type { TaskBoard } from "./board.js";
2
+ import { type TaskDocument } from "./document.js";
3
+ import { type TaskId } from "./identity.js";
4
+ import { type TaskCompositionDiagnostic } from "./operations.js";
5
+ export type TaskCompositionAlias = Readonly<{
6
+ alias: string;
7
+ taskId: TaskId;
8
+ }>;
9
+ export type TaskCompositionBodyPreview = Readonly<{
10
+ taskId: TaskId;
11
+ bytes: number;
12
+ firstLine: string;
13
+ lastLine: string;
14
+ }>;
15
+ export type PlannedTask = Readonly<{
16
+ index: number;
17
+ line: number;
18
+ kind: "new" | "existing";
19
+ alias?: string;
20
+ before: TaskDocument | null;
21
+ after: TaskDocument;
22
+ }>;
23
+ export type TaskCompositionPlan = Readonly<{
24
+ namespace: readonly string[];
25
+ aliases: readonly TaskCompositionAlias[];
26
+ admissionOrder: readonly TaskId[];
27
+ bodies: readonly TaskCompositionBodyPreview[];
28
+ tasks: readonly PlannedTask[];
29
+ }>;
30
+ export type TaskCompositionPlanning = Readonly<{
31
+ kind: "planned";
32
+ plan: TaskCompositionPlan;
33
+ }> | Readonly<{
34
+ kind: "refused";
35
+ diagnostics: readonly TaskCompositionDiagnostic[];
36
+ }>;
37
+ export declare function planTaskComposition(input: Readonly<{
38
+ markdown: string;
39
+ board: TaskBoard;
40
+ namespace: readonly string[];
41
+ at: string;
42
+ actor?: string;
43
+ }>): TaskCompositionPlanning;
@@ -0,0 +1,321 @@
1
+ import { serializeTaskDocument } from "./document.js";
2
+ import { allocateLocalId, deriveLocalStem, formatTaskId, parseTaskId, sameNamespace } from "./identity.js";
3
+ import { parseTaskComposition } from "./compose-parser.js";
4
+ import { advanceTaskTimestamp } from "./operations.js";
5
+ function diagnostic(line, reason, token) {
6
+ return { line, reason, token };
7
+ }
8
+ function occupied(board, namespace) {
9
+ return new Set([...board.tasks.values()].flatMap((task) => {
10
+ const coordinate = parseTaskId(task.id);
11
+ return sameNamespace(coordinate.namespace, namespace) ? [coordinate.localId] : [];
12
+ }));
13
+ }
14
+ function createdTask(id, title, at, actor) {
15
+ return {
16
+ id,
17
+ title,
18
+ state: "open",
19
+ priority: 2,
20
+ needs: [],
21
+ parent: null,
22
+ supersedes: [],
23
+ relates: [],
24
+ note: "",
25
+ ...(actor === undefined ? {} : { createdBy: actor }),
26
+ createdAt: at,
27
+ updatedAt: at,
28
+ body: "",
29
+ };
30
+ }
31
+ function aliasFor(node) {
32
+ return node.assignments.find((item) => item.field === "as");
33
+ }
34
+ function allocateNodes(input, diagnostics) {
35
+ const ids = occupied(input.board, input.namespace);
36
+ const allocations = new Map();
37
+ for (const node of input.parsed.nodes) {
38
+ if (node.kind !== "new")
39
+ continue;
40
+ try {
41
+ const localId = allocateLocalId(deriveLocalStem(node.title), ids);
42
+ ids.add(localId);
43
+ const id = formatTaskId({ namespace: input.namespace, localId });
44
+ allocations.set(node.index, createdTask(id, node.title, input.at, input.actor));
45
+ }
46
+ catch (error) {
47
+ diagnostics.push(diagnostic(node.line, error instanceof Error ? error.message : String(error), node.title));
48
+ }
49
+ }
50
+ return allocations;
51
+ }
52
+ function collectAliases(nodes, allocations, diagnostics) {
53
+ const aliases = new Map();
54
+ for (const node of nodes) {
55
+ const declarations = node.assignments.filter((item) => item.field === "as");
56
+ for (const duplicate of declarations.slice(1)) {
57
+ diagnostics.push(diagnostic(duplicate.line, "alias is assigned more than once", duplicate.token));
58
+ }
59
+ const declaration = declarations[0];
60
+ if (declaration === undefined)
61
+ continue;
62
+ if (node.kind !== "new") {
63
+ diagnostics.push(diagnostic(declaration.line, "only a new task may declare an alias", declaration.token));
64
+ continue;
65
+ }
66
+ if (declaration.operator !== "=" || !/^[a-z0-9-]+$/u.test(declaration.value)) {
67
+ diagnostics.push(diagnostic(declaration.line, "alias must use as = [a-z0-9-]+", declaration.token));
68
+ continue;
69
+ }
70
+ if (aliases.has(declaration.value)) {
71
+ diagnostics.push(diagnostic(declaration.line, "alias must be unique in the composition", declaration.value));
72
+ continue;
73
+ }
74
+ const allocated = allocations.get(node.index);
75
+ if (allocated !== undefined)
76
+ aliases.set(declaration.value, allocated.id);
77
+ }
78
+ return aliases;
79
+ }
80
+ function resolveReference(raw, line, board, aliases, diagnostics) {
81
+ if (raw.startsWith("^")) {
82
+ const alias = raw.slice(1);
83
+ const resolved = aliases.get(alias);
84
+ if (resolved === undefined)
85
+ diagnostics.push(diagnostic(line, "new-task alias is not declared", raw));
86
+ return resolved;
87
+ }
88
+ if (!raw.startsWith("@task/")) {
89
+ diagnostics.push(diagnostic(line, "reference must be @task/... or ^alias", raw));
90
+ return undefined;
91
+ }
92
+ try {
93
+ const id = raw.slice(1);
94
+ parseTaskId(id);
95
+ if (!board.tasks.has(id))
96
+ diagnostics.push(diagnostic(line, "@ reference must name a pre-existing task", raw));
97
+ return board.tasks.has(id) ? id : undefined;
98
+ }
99
+ catch (error) {
100
+ diagnostics.push(diagnostic(line, error instanceof Error ? error.message : String(error), raw));
101
+ return undefined;
102
+ }
103
+ }
104
+ function referenceList(assignment, board, aliases, diagnostics) {
105
+ if (assignment.value === "")
106
+ return [];
107
+ const raw = assignment.value.split(",").map((item) => item.trim());
108
+ if (raw.some((item) => item.length === 0)) {
109
+ diagnostics.push(diagnostic(assignment.line, "reference list contains an empty item", assignment.token));
110
+ return undefined;
111
+ }
112
+ const resolved = raw.map((item) => resolveReference(item, assignment.line, board, aliases, diagnostics));
113
+ if (resolved.some((item) => item === undefined))
114
+ return undefined;
115
+ const ids = resolved;
116
+ if (new Set(ids).size !== ids.length) {
117
+ diagnostics.push(diagnostic(assignment.line, "reference list contains a duplicate", assignment.token));
118
+ return undefined;
119
+ }
120
+ return ids;
121
+ }
122
+ function patchRelation(current, assignment, values, diagnostics) {
123
+ if (assignment.operator === "=")
124
+ return values;
125
+ if (assignment.operator === "+=")
126
+ return [...current, ...values.filter((id) => !current.includes(id))];
127
+ const missing = values.filter((id) => !current.includes(id));
128
+ for (const id of missing)
129
+ diagnostics.push(diagnostic(assignment.line, "cannot remove an absent relation", `@${id}`));
130
+ return missing.length === 0 ? current.filter((id) => !values.includes(id)) : current;
131
+ }
132
+ function applyAssignments(node, base, board, aliases, diagnostics) {
133
+ let next = base;
134
+ const scalar = new Set();
135
+ for (const assignment of node.assignments) {
136
+ if (assignment.field === "as")
137
+ continue;
138
+ if (assignment.field === "pri" || assignment.field === "parent") {
139
+ if (scalar.has(assignment.field)) {
140
+ diagnostics.push(diagnostic(assignment.line, `${assignment.field} is assigned more than once`, assignment.token));
141
+ }
142
+ scalar.add(assignment.field);
143
+ }
144
+ if (assignment.field === "pri") {
145
+ if (assignment.operator !== "=" || !/^[0-3]$/u.test(assignment.value)) {
146
+ diagnostics.push(diagnostic(assignment.line, "pri must use = with a value from 0 through 3", assignment.token));
147
+ }
148
+ else
149
+ next = { ...next, priority: Number(assignment.value) };
150
+ continue;
151
+ }
152
+ if (assignment.field === "parent") {
153
+ if (assignment.operator !== "=") {
154
+ diagnostics.push(diagnostic(assignment.line, "parent accepts only =", assignment.token));
155
+ }
156
+ else if (assignment.value === "")
157
+ next = { ...next, parent: null };
158
+ else {
159
+ const id = resolveReference(assignment.value.trim(), assignment.line, board, aliases, diagnostics);
160
+ if (id !== undefined)
161
+ next = { ...next, parent: id };
162
+ }
163
+ continue;
164
+ }
165
+ const values = referenceList(assignment, board, aliases, diagnostics);
166
+ if (values === undefined)
167
+ continue;
168
+ if (assignment.operator !== "=" && values.length === 0) {
169
+ diagnostics.push(diagnostic(assignment.line, `${assignment.operator} requires at least one reference`, assignment.token));
170
+ continue;
171
+ }
172
+ next = { ...next, [assignment.field]: patchRelation(next[assignment.field], assignment, values, diagnostics) };
173
+ }
174
+ if (node.body !== undefined)
175
+ next = { ...next, body: node.body.value };
176
+ for (const field of ["needs", "supersedes", "relates"]) {
177
+ if (next[field].includes(next.id))
178
+ diagnostics.push(diagnostic(node.line, `${field} cannot reference the task itself`, next.id));
179
+ }
180
+ if (next.parent === next.id)
181
+ diagnostics.push(diagnostic(node.line, "parent cannot reference the task itself", next.id));
182
+ return next;
183
+ }
184
+ function relationTargets(document, relation) {
185
+ return relation === "needs" ? document.needs : document.parent === null ? [] : [document.parent];
186
+ }
187
+ function pathExists(board, start, goal, relation) {
188
+ const seen = new Set();
189
+ const pending = [start];
190
+ while (pending.length > 0) {
191
+ const id = pending.pop();
192
+ if (id === goal)
193
+ return true;
194
+ if (seen.has(id))
195
+ continue;
196
+ seen.add(id);
197
+ const task = board.tasks.get(id);
198
+ if (task !== undefined)
199
+ pending.push(...relationTargets(task, relation));
200
+ }
201
+ return false;
202
+ }
203
+ function diagnoseIntroducedCycles(after, tasks, diagnostics) {
204
+ for (const task of tasks) {
205
+ for (const relation of ["needs", "parent"]) {
206
+ const old = task.before === null ? [] : relationTargets(task.before, relation);
207
+ for (const target of relationTargets(task.after, relation)) {
208
+ if (!old.includes(target) && pathExists(after, target, task.after.id, relation)) {
209
+ diagnostics.push(diagnostic(task.line, `${relation} edge creates a cycle`, `${task.after.id} -> ${target}`));
210
+ }
211
+ }
212
+ }
213
+ }
214
+ }
215
+ function changed(task) {
216
+ return (task.before === null ||
217
+ !Buffer.from(serializeTaskDocument(task.before)).equals(Buffer.from(serializeTaskDocument(task.after))));
218
+ }
219
+ function stableAdmissionOrder(tasks, diagnostics) {
220
+ const candidates = tasks.filter(changed);
221
+ const byId = new Map(candidates.map((task) => [task.after.id, task]));
222
+ const dependencies = new Map(candidates.map((task) => [task.after.id, new Set()]));
223
+ for (const task of candidates) {
224
+ const targets = [...task.after.needs, ...(task.after.parent === null ? [] : [task.after.parent])];
225
+ for (const target of targets) {
226
+ const plannedTarget = byId.get(target);
227
+ if (plannedTarget?.before === null)
228
+ dependencies.get(task.after.id).add(target);
229
+ }
230
+ }
231
+ const remaining = new Set(candidates.map((task) => task.after.id));
232
+ const ordered = [];
233
+ while (remaining.size > 0) {
234
+ const ready = candidates.find((task) => remaining.has(task.after.id) && [...dependencies.get(task.after.id)].every((id) => !remaining.has(id)));
235
+ if (ready === undefined) {
236
+ const first = candidates.find((task) => remaining.has(task.after.id));
237
+ diagnostics.push(diagnostic(first.line, "needs and parent references create an admission cycle", first.after.id));
238
+ return [];
239
+ }
240
+ remaining.delete(ready.after.id);
241
+ ordered.push(ready);
242
+ }
243
+ return ordered;
244
+ }
245
+ function bodyPreview(node, id) {
246
+ if (node.body?.kind !== "replace")
247
+ return null;
248
+ const lines = node.body.value.split(/\r\n|\n|\r/u);
249
+ return {
250
+ taskId: id,
251
+ bytes: Buffer.byteLength(node.body.value),
252
+ firstLine: lines[0] ?? "",
253
+ lastLine: lines.at(-1) ?? "",
254
+ };
255
+ }
256
+ export function planTaskComposition(input) {
257
+ const parsed = parseTaskComposition(input.markdown);
258
+ const diagnostics = [...parsed.diagnostics];
259
+ const namespace = parsed.namespace ?? input.namespace;
260
+ const allocationInput = {
261
+ parsed,
262
+ board: input.board,
263
+ namespace,
264
+ at: input.at,
265
+ ...(input.actor === undefined ? {} : { actor: input.actor }),
266
+ };
267
+ const allocations = allocateNodes(allocationInput, diagnostics);
268
+ const aliases = collectAliases(parsed.nodes, allocations, diagnostics);
269
+ const addressed = new Set();
270
+ const tasks = [];
271
+ for (const node of parsed.nodes) {
272
+ const before = node.kind === "new" ? null : (input.board.tasks.get(node.id) ?? null);
273
+ const base = node.kind === "new" ? allocations.get(node.index) : before;
274
+ if (base === null || base === undefined) {
275
+ diagnostics.push(diagnostic(node.line, "existing task does not exist before composition", `@${node.id}`));
276
+ continue;
277
+ }
278
+ if (addressed.has(base.id)) {
279
+ diagnostics.push(diagnostic(node.line, "task is addressed more than once", base.id));
280
+ continue;
281
+ }
282
+ addressed.add(base.id);
283
+ let after = applyAssignments(node, base, input.board, aliases, diagnostics);
284
+ if (before !== null && changed({ index: node.index, line: node.line, kind: node.kind, before, after })) {
285
+ after = { ...after, updatedAt: advanceTaskTimestamp(before.updatedAt, input.at) };
286
+ }
287
+ tasks.push({
288
+ index: node.index,
289
+ line: node.line,
290
+ kind: node.kind,
291
+ ...(aliasFor(node) === undefined ? {} : { alias: aliasFor(node).value }),
292
+ before,
293
+ after,
294
+ });
295
+ }
296
+ const post = new Map(input.board.tasks);
297
+ for (const task of tasks)
298
+ post.set(task.after.id, task.after);
299
+ diagnoseIntroducedCycles({ tasks: post }, tasks, diagnostics);
300
+ const ordered = stableAdmissionOrder(tasks, diagnostics);
301
+ if (diagnostics.length > 0)
302
+ return { kind: "refused", diagnostics };
303
+ const aliasBindings = [...aliases].map(([alias, taskId]) => ({ alias, taskId }));
304
+ const bodies = parsed.nodes.flatMap((node) => {
305
+ const task = tasks.find((candidate) => candidate.index === node.index);
306
+ if (task === undefined)
307
+ return [];
308
+ const preview = bodyPreview(node, task.after.id);
309
+ return preview === null ? [] : [preview];
310
+ });
311
+ return {
312
+ kind: "planned",
313
+ plan: {
314
+ namespace,
315
+ aliases: aliasBindings,
316
+ admissionOrder: ordered.map((task) => task.after.id),
317
+ bodies,
318
+ tasks: ordered,
319
+ },
320
+ };
321
+ }
@@ -0,0 +1,31 @@
1
+ import { type TaskId } from "./identity.js";
2
+ import type { TaskCompositionDiagnostic } from "./operations.js";
3
+ export type RelationField = "needs" | "supersedes" | "relates";
4
+ export type Assignment = Readonly<{
5
+ field: "as" | "parent" | "pri" | RelationField;
6
+ operator: "=" | "+=" | "-=";
7
+ value: string;
8
+ line: number;
9
+ token: string;
10
+ }>;
11
+ export type BodyAssignment = Readonly<{
12
+ kind: "clear" | "replace";
13
+ value: string;
14
+ line: number;
15
+ token: string;
16
+ }>;
17
+ export type ParsedNode = Readonly<{
18
+ index: number;
19
+ line: number;
20
+ kind: "new" | "existing";
21
+ title?: string;
22
+ id?: TaskId;
23
+ assignments: readonly Assignment[];
24
+ body?: BodyAssignment;
25
+ }>;
26
+ export type ParsedComposition = Readonly<{
27
+ namespace?: readonly string[];
28
+ nodes: readonly ParsedNode[];
29
+ diagnostics: readonly TaskCompositionDiagnostic[];
30
+ }>;
31
+ export declare function parseTaskComposition(source: string): ParsedComposition;