@astrosheep/keiyaku 4.5.2 → 4.5.4
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/build/integrations/marketplace/plugins/keiyaku/.claude-plugin/plugin.json +1 -1
- package/build/integrations/marketplace/plugins/keiyaku/.codex-plugin/plugin.json +1 -1
- package/build/integrations/marketplace/plugins/keiyaku/package.json +4 -1
- package/build/integrations/pi/keiyaku.ts +19 -11
- package/build/src/cli/commands/task-invoke.d.ts +2 -0
- package/build/src/cli/commands/task-invoke.js +15 -2
- package/build/src/cli/commands/task.js +21 -3
- package/build/src/cli/invoke.js +1 -0
- package/build/src/cli/render/task.js +25 -1
- package/build/src/task/compose-language.d.ts +43 -0
- package/build/src/task/compose-language.js +314 -0
- package/build/src/task/compose-parser.d.ts +31 -0
- package/build/src/task/compose-parser.js +162 -0
- package/build/src/task/compose.d.ts +20 -9
- package/build/src/task/compose.js +124 -287
- package/build/src/task/index.d.ts +3 -2
- package/build/src/task/index.js +4 -2
- package/build/src/task/operations.d.ts +6 -1
- package/package.json +12 -2
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "keiyaku-harness",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./opencode.js",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"pi-package"
|
|
8
8
|
],
|
|
9
9
|
"pi": {
|
|
10
|
+
"extensions": [
|
|
11
|
+
"../../../pi/keiyaku.ts"
|
|
12
|
+
],
|
|
10
13
|
"skills": [
|
|
11
14
|
"./skills"
|
|
12
15
|
]
|
|
@@ -100,7 +100,7 @@ function overlay(lines: readonly string[], theme: Theme, done: () => void) {
|
|
|
100
100
|
if (data === "\u001b" || data === "q" || data === "Q" || data === "\r") done();
|
|
101
101
|
},
|
|
102
102
|
render(width: number): string[] {
|
|
103
|
-
const inner = Math.max(
|
|
103
|
+
const inner = Math.max(1, Math.min(100, width - 2));
|
|
104
104
|
const border = theme.fg("border", "+");
|
|
105
105
|
const rule = theme.fg("border", "-".repeat(inner));
|
|
106
106
|
return [
|
|
@@ -124,23 +124,31 @@ export default function keiyakuExtension(pi: ExtensionAPI): void {
|
|
|
124
124
|
const refresh = async (ctx: Pick<ExtensionContext, "hasUI" | "ui">): Promise<void> => {
|
|
125
125
|
if (!ctx.hasUI) return;
|
|
126
126
|
refreshInFlight ??= (async () => {
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
127
|
+
try {
|
|
128
|
+
const result = await run(pi, ["status", "--json"]);
|
|
129
|
+
const report = parseReport(result);
|
|
130
|
+
if (report === undefined) {
|
|
131
|
+
const diagnostic = result.stderr.trim() || "status unavailable";
|
|
132
|
+
ctx.ui.setWidget("keiyaku", [`Keiyaku · ${lineForWidth(diagnostic, 100)}`]);
|
|
133
|
+
} else ctx.ui.setWidget("keiyaku", [summary(report)]);
|
|
134
|
+
} catch {
|
|
135
|
+
ctx.ui.setWidget("keiyaku", ["Keiyaku · status unavailable"]);
|
|
136
|
+
}
|
|
133
137
|
})().finally(() => {
|
|
134
138
|
refreshInFlight = undefined;
|
|
135
139
|
});
|
|
136
140
|
await refreshInFlight;
|
|
137
141
|
};
|
|
138
142
|
|
|
139
|
-
|
|
140
|
-
|
|
143
|
+
const scheduleRefresh = (ctx: Pick<ExtensionContext, "hasUI" | "ui">): void => {
|
|
144
|
+
void refresh(ctx).catch(() => undefined);
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
pi.on("session_start", (_event, ctx) => {
|
|
148
|
+
scheduleRefresh(ctx);
|
|
141
149
|
});
|
|
142
|
-
pi.on("
|
|
143
|
-
|
|
150
|
+
pi.on("agent_end", (_event, ctx) => {
|
|
151
|
+
scheduleRefresh(ctx);
|
|
144
152
|
});
|
|
145
153
|
pi.registerCommand("keiyaku", {
|
|
146
154
|
description: "Open the Kanshi world status",
|
|
@@ -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,15 @@ 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 {
|
|
299
|
+
kind: "refused",
|
|
300
|
+
refusal: {
|
|
301
|
+
kind: "invalid-composition",
|
|
302
|
+
diagnostics: [{ line: 1, reason: "task world has no valid planning coordinate", token: "--plan" }],
|
|
303
|
+
},
|
|
304
|
+
};
|
|
305
|
+
}
|
|
296
306
|
if (command.action === "namespace")
|
|
297
307
|
return { kind: "accepted", value: [] };
|
|
298
308
|
if (command.action === "hold" || command.action === "done" || command.action === "drop") {
|
|
@@ -338,6 +348,7 @@ async function invokeLocalMutation(tasks, command, input, current) {
|
|
|
338
348
|
markdown: await input.readStdin(),
|
|
339
349
|
namespace: current ?? [],
|
|
340
350
|
...(input.actor === undefined ? {} : { actor: input.actor }),
|
|
351
|
+
...(command.flags.plan === true ? { plan: true } : {}),
|
|
341
352
|
});
|
|
342
353
|
default:
|
|
343
354
|
throw new Error(`task action has no invocation: ${command.action}`);
|
|
@@ -345,17 +356,19 @@ async function invokeLocalMutation(tasks, command, input, current) {
|
|
|
345
356
|
}
|
|
346
357
|
function establishesWorld(command) {
|
|
347
358
|
return (command.action === "add" ||
|
|
348
|
-
command.action === "compose" ||
|
|
359
|
+
(command.action === "compose" && command.flags.plan !== true) ||
|
|
349
360
|
(command.action === "namespace" && command.positionals.length > 0));
|
|
350
361
|
}
|
|
351
362
|
function forwardsMutation(command) {
|
|
352
363
|
return (command.action !== "show" &&
|
|
353
364
|
command.action !== "tree" &&
|
|
354
365
|
command.action !== "namespace" &&
|
|
366
|
+
!(command.action === "compose" && command.flags.plan === true) &&
|
|
355
367
|
!isWorldObservation(command));
|
|
356
368
|
}
|
|
357
369
|
export async function invokeTask(command, input) {
|
|
358
|
-
const
|
|
370
|
+
const planOnly = command.action === "compose" && command.flags.plan === true;
|
|
371
|
+
const world = input.world ?? (planOnly ? input.candidate : establishesWorld(command) ? await input.establish() : null);
|
|
359
372
|
if (world === null)
|
|
360
373
|
return missingWorld(command);
|
|
361
374
|
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: "
|
|
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) {
|
package/build/src/cli/invoke.js
CHANGED
|
@@ -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,314 @@
|
|
|
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(parsed, board, namespace, at, actor, diagnostics) {
|
|
35
|
+
const ids = occupied(board, namespace);
|
|
36
|
+
const allocations = new Map();
|
|
37
|
+
for (const node of 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, localId });
|
|
44
|
+
allocations.set(node.index, createdTask(id, node.title, at, 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 allocations = allocateNodes(parsed, input.board, namespace, input.at, input.actor, diagnostics);
|
|
261
|
+
const aliases = collectAliases(parsed.nodes, allocations, diagnostics);
|
|
262
|
+
const addressed = new Set();
|
|
263
|
+
const tasks = [];
|
|
264
|
+
for (const node of parsed.nodes) {
|
|
265
|
+
const before = node.kind === "new" ? null : (input.board.tasks.get(node.id) ?? null);
|
|
266
|
+
const base = node.kind === "new" ? allocations.get(node.index) : before;
|
|
267
|
+
if (base === null || base === undefined) {
|
|
268
|
+
diagnostics.push(diagnostic(node.line, "existing task does not exist before composition", `@${node.id}`));
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
if (addressed.has(base.id)) {
|
|
272
|
+
diagnostics.push(diagnostic(node.line, "task is addressed more than once", base.id));
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
addressed.add(base.id);
|
|
276
|
+
let after = applyAssignments(node, base, input.board, aliases, diagnostics);
|
|
277
|
+
if (before !== null && changed({ index: node.index, line: node.line, kind: node.kind, before, after })) {
|
|
278
|
+
after = { ...after, updatedAt: advanceTaskTimestamp(before.updatedAt, input.at) };
|
|
279
|
+
}
|
|
280
|
+
tasks.push({
|
|
281
|
+
index: node.index,
|
|
282
|
+
line: node.line,
|
|
283
|
+
kind: node.kind,
|
|
284
|
+
...(aliasFor(node) === undefined ? {} : { alias: aliasFor(node).value }),
|
|
285
|
+
before,
|
|
286
|
+
after,
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
const post = new Map(input.board.tasks);
|
|
290
|
+
for (const task of tasks)
|
|
291
|
+
post.set(task.after.id, task.after);
|
|
292
|
+
diagnoseIntroducedCycles({ tasks: post }, tasks, diagnostics);
|
|
293
|
+
const ordered = stableAdmissionOrder(tasks, diagnostics);
|
|
294
|
+
if (diagnostics.length > 0)
|
|
295
|
+
return { kind: "refused", diagnostics };
|
|
296
|
+
const aliasBindings = [...aliases].map(([alias, taskId]) => ({ alias, taskId }));
|
|
297
|
+
const bodies = parsed.nodes.flatMap((node) => {
|
|
298
|
+
const task = tasks.find((candidate) => candidate.index === node.index);
|
|
299
|
+
if (task === undefined)
|
|
300
|
+
return [];
|
|
301
|
+
const preview = bodyPreview(node, task.after.id);
|
|
302
|
+
return preview === null ? [] : [preview];
|
|
303
|
+
});
|
|
304
|
+
return {
|
|
305
|
+
kind: "planned",
|
|
306
|
+
plan: {
|
|
307
|
+
namespace,
|
|
308
|
+
aliases: aliasBindings,
|
|
309
|
+
admissionOrder: ordered.map((task) => task.after.id),
|
|
310
|
+
bodies,
|
|
311
|
+
tasks: ordered,
|
|
312
|
+
},
|
|
313
|
+
};
|
|
314
|
+
}
|
|
@@ -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;
|