@danypops/papyrus 0.1.0 → 0.2.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/README.md +22 -10
- package/extension/src/active-task-continuation.ts +122 -0
- package/extension/src/{facade-tools.ts → domain-tools.ts} +26 -7
- package/extension/src/index.ts +50 -5
- package/extension/src/task-graph.ts +3 -3
- package/extension/src/tasks.ts +26 -9
- package/package.json +1 -1
- package/src/adapters/sqlite-artifact-store.ts +7 -0
- package/src/cli.ts +103 -3
- package/src/constants.ts +10 -4
- package/src/db.ts +1 -1
- package/src/domain/artifact.ts +1 -0
- package/src/domain/skill-definition.ts +217 -0
- package/src/{facades.ts → domain-services.ts} +1 -1
- package/src/service.ts +14 -3
- package/src/task-execution.ts +124 -0
- package/src/task-graph-view.ts +25 -6
- package/src/task-service.ts +98 -6
- package/src/version.ts +16 -0
package/src/cli.ts
CHANGED
|
@@ -4,8 +4,12 @@ import { mkdirSync, writeFileSync } from "node:fs";
|
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
5
|
import { dirname, join } from "node:path";
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { connectPapyrusClient, type PapyrusClient } from "./client.ts";
|
|
7
8
|
import { DAEMON_UNIT_NAME } from "./constants.ts";
|
|
8
9
|
import { serveMain } from "./daemon.ts";
|
|
10
|
+
import type { GateResult } from "./domain/gate.ts";
|
|
11
|
+
import type { TaskExecutionPlan } from "./task-execution.ts";
|
|
12
|
+
import type { TaskBlockage, TaskCompletion } from "./task-service.ts";
|
|
9
13
|
|
|
10
14
|
export interface SystemdUnitOptions {
|
|
11
15
|
bunBin: string;
|
|
@@ -49,14 +53,105 @@ function installService(): void {
|
|
|
49
53
|
systemctl("restart", DAEMON_UNIT_NAME);
|
|
50
54
|
}
|
|
51
55
|
|
|
56
|
+
const USAGE = `Usage:
|
|
57
|
+
papyrus serve
|
|
58
|
+
papyrus service <install|start|stop|restart|status>
|
|
59
|
+
papyrus tasks plan [--json]
|
|
60
|
+
papyrus tasks complete <id> [--json]
|
|
61
|
+
papyrus tasks start <id> [--json]
|
|
62
|
+
papyrus tasks depend <id> <prerequisite-id> [--json]`;
|
|
63
|
+
|
|
52
64
|
function usage(): never {
|
|
53
|
-
console.error(
|
|
65
|
+
console.error(USAGE);
|
|
54
66
|
process.exit(2);
|
|
55
67
|
}
|
|
56
68
|
|
|
57
|
-
|
|
69
|
+
type TaskCliClient = Pick<PapyrusClient, "call">;
|
|
70
|
+
type CliArtifact = { id: string; title: string; status: string };
|
|
71
|
+
type CliCompletion = Omit<TaskCompletion, "artifact" | "started" | "blocked"> & {
|
|
72
|
+
artifact: CliArtifact;
|
|
73
|
+
started: CliArtifact[];
|
|
74
|
+
blocked: Array<Omit<TaskBlockage, "artifact"> & { artifact: CliArtifact }>;
|
|
75
|
+
gates: GateResult[];
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
function artifactLabel(artifact: CliArtifact): string {
|
|
79
|
+
return `${artifact.id} ${artifact.title}`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function planText(plan: TaskExecutionPlan): string {
|
|
83
|
+
const byId = new Map(plan.nodes.map((node) => [node.id, node]));
|
|
84
|
+
const lines = ["Execution order:"];
|
|
85
|
+
plan.layers.forEach((layer, index) => {
|
|
86
|
+
lines.push(` Layer ${index + 1}:`);
|
|
87
|
+
for (const id of layer) {
|
|
88
|
+
const node = byId.get(id);
|
|
89
|
+
lines.push(node ? ` [${node.state}] ${node.id} ${node.title}` : ` [unknown] ${id}`);
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
if (plan.layers.length === 0) lines.push(" (no tasks)");
|
|
93
|
+
if (plan.cycleIds.length > 0) lines.push(` Invalid cycle: ${plan.cycleIds.join(", ")}`);
|
|
94
|
+
return lines.join("\n");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function runTaskCli(args: string[], client: TaskCliClient): Promise<string> {
|
|
98
|
+
const json = args.includes("--json");
|
|
99
|
+
const positional = args.filter((arg) => arg !== "--json");
|
|
100
|
+
const [action, id, dependencyId] = positional;
|
|
101
|
+
let result: unknown;
|
|
102
|
+
let human: string;
|
|
103
|
+
switch (action) {
|
|
104
|
+
case "plan": {
|
|
105
|
+
if (id) throw new Error("tasks plan accepts no positional arguments");
|
|
106
|
+
const plan = await client.call<Record<string, never>, TaskExecutionPlan>("tasks.plan", {});
|
|
107
|
+
result = plan;
|
|
108
|
+
human = planText(plan);
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
111
|
+
case "complete": {
|
|
112
|
+
if (!id || dependencyId) throw new Error("tasks complete requires exactly one task id");
|
|
113
|
+
const completion = await client.call<{ id: string }, CliCompletion>("tasks.complete", { id });
|
|
114
|
+
result = completion;
|
|
115
|
+
const lines = [`${completion.completed ? "Completed" : "Not completed"}: ${artifactLabel(completion.artifact)}`];
|
|
116
|
+
if (completion.started.length > 0) lines.push(`Started: ${completion.started.map(artifactLabel).join(", ")}`);
|
|
117
|
+
if (completion.blocked.length > 0) {
|
|
118
|
+
lines.push(`Blocked: ${completion.blocked.map((entry) => `${artifactLabel(entry.artifact)} waits for ${entry.dependencyIds.join(", ")}`).join("; ")}`);
|
|
119
|
+
}
|
|
120
|
+
for (const gate of completion.gates) lines.push(`${gate.passed ? "✓" : "✗"} ${gate.gate.type}: ${gate.gate.target} — ${gate.output}`);
|
|
121
|
+
human = lines.join("\n");
|
|
122
|
+
break;
|
|
123
|
+
}
|
|
124
|
+
case "start": {
|
|
125
|
+
if (!id || dependencyId) throw new Error("tasks start requires exactly one task id");
|
|
126
|
+
const artifact = await client.call<{ id: string }, CliArtifact>("tasks.start", { id });
|
|
127
|
+
result = artifact;
|
|
128
|
+
human = `Started: ${artifactLabel(artifact)}`;
|
|
129
|
+
break;
|
|
130
|
+
}
|
|
131
|
+
case "depend": {
|
|
132
|
+
if (!id || !dependencyId || positional.length !== 3) throw new Error("tasks depend requires a task id and prerequisite id");
|
|
133
|
+
const artifact = await client.call<{ id: string; dependency_id: string }, CliArtifact>("tasks.depend", {
|
|
134
|
+
id,
|
|
135
|
+
dependency_id: dependencyId,
|
|
136
|
+
});
|
|
137
|
+
result = artifact;
|
|
138
|
+
human = `Dependency added: ${artifactLabel(artifact)} waits for ${dependencyId}`;
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
default:
|
|
142
|
+
throw new Error("tasks action must be plan, complete, start, or depend");
|
|
143
|
+
}
|
|
144
|
+
return json ? JSON.stringify(result) : human;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export async function main(args: string[] = process.argv.slice(2)): Promise<void> {
|
|
58
148
|
const [command, action] = args;
|
|
59
149
|
if (command === "serve") { serveMain(); return; }
|
|
150
|
+
if (command === "tasks") {
|
|
151
|
+
const client = await connectPapyrusClient();
|
|
152
|
+
console.log(await runTaskCli(args.slice(1), client));
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
60
155
|
if (command !== "service") usage();
|
|
61
156
|
switch (action) {
|
|
62
157
|
case "install": installService(); break;
|
|
@@ -68,4 +163,9 @@ export function main(args: string[] = process.argv.slice(2)): void {
|
|
|
68
163
|
}
|
|
69
164
|
}
|
|
70
165
|
|
|
71
|
-
if (import.meta.main)
|
|
166
|
+
if (import.meta.main) {
|
|
167
|
+
void main().catch((error) => {
|
|
168
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
169
|
+
process.exitCode = 1;
|
|
170
|
+
});
|
|
171
|
+
}
|
package/src/constants.ts
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
export const VERSION = "0.1.0";
|
|
2
|
-
|
|
3
1
|
/** Long-running daemon transport and state. */
|
|
4
2
|
export const DAEMON_HOST = "127.0.0.1";
|
|
5
3
|
export const DAEMON_PORT_FILE = "port";
|
|
@@ -30,6 +28,14 @@ export const TASK_GRAPH_MIN_VISIBLE_LINES = 8;
|
|
|
30
28
|
export const TASK_GRAPH_MAX_VISIBLE_LINES = 30;
|
|
31
29
|
export const TASK_GRAPH_RESERVED_ROWS = 8;
|
|
32
30
|
export const TASK_GRAPH_HORIZONTAL_PAN_COLUMNS = 4;
|
|
31
|
+
/** Hard bounds for executable dependency DAG projection and cycle checks. */
|
|
32
|
+
export const TASK_EXECUTION_MAX_NODES = 1_000;
|
|
33
|
+
export const TASK_EXECUTION_MAX_EDGES = 10_000;
|
|
34
|
+
export const TASK_EXECUTION_MAX_DEGREE = 100;
|
|
35
|
+
/** Bounded automatic Pi continuations while active Papyrus Tasks remain. */
|
|
36
|
+
export const TASK_DRIVER_ACTIVE_LIMIT = 4;
|
|
37
|
+
export const TASK_DRIVER_MAX_TURNS = 20;
|
|
38
|
+
export const TASK_DRIVER_MAX_UNCHANGED_TURNS = 6;
|
|
33
39
|
export const GRAPH_RENDER_PADDING_X = 2;
|
|
34
40
|
export const GRAPH_RENDER_PADDING_Y = 1;
|
|
35
41
|
export const GRAPH_RENDER_BOX_PADDING = 0;
|
|
@@ -69,13 +75,13 @@ export function dbPath(): string {
|
|
|
69
75
|
* rule = Governance — context injection ("when doing X, follow Y").
|
|
70
76
|
* Maps to AGENTS.md semantics: active rules with inject:true are
|
|
71
77
|
* appended to the system prompt on before_agent_start.
|
|
72
|
-
* skill =
|
|
78
|
+
* skill = Parameterized workflow bundle — validated inputs render connected Task, Rule, and Doc collections.
|
|
73
79
|
*/
|
|
74
80
|
export const SEED_KINDS = [
|
|
75
81
|
{ name: "doc", description: "Knowledge — descriptive reference (specs, decisions, research, designs)" },
|
|
76
82
|
{ name: "task", description: "Work — action items with gates, checklists, and dependencies" },
|
|
77
83
|
{ name: "rule", description: "Governance — context injection (when doing X, follow Y). Maps to AGENTS.md" },
|
|
78
|
-
{ name: "skill", description: "
|
|
84
|
+
{ name: "skill", description: "Parameterized workflow bundle — inputs and templates load deterministic tasks plus contextual rules and docs" },
|
|
79
85
|
] as const;
|
|
80
86
|
|
|
81
87
|
export const SEED_STATUSES = [
|
package/src/db.ts
CHANGED
|
@@ -80,7 +80,7 @@ const SEED_SQL = `
|
|
|
80
80
|
INSERT OR IGNORE INTO kinds VALUES ('doc','Knowledge — what we know (specs, decisions, research, designs)');
|
|
81
81
|
INSERT OR IGNORE INTO kinds VALUES ('task','Work — what we are doing (goals, steps, checklists)');
|
|
82
82
|
INSERT OR IGNORE INTO kinds VALUES ('rule','Governance — when doing X, follow Y');
|
|
83
|
-
INSERT OR IGNORE INTO kinds VALUES ('skill','
|
|
83
|
+
INSERT OR IGNORE INTO kinds VALUES ('skill','Parameterized workflow bundle — inputs and templates load tasks, rules, and docs');
|
|
84
84
|
INSERT OR IGNORE INTO statuses VALUES ('draft','doc');
|
|
85
85
|
INSERT OR IGNORE INTO statuses VALUES ('active','doc');
|
|
86
86
|
INSERT OR IGNORE INTO statuses VALUES ('archived','doc');
|
package/src/domain/artifact.ts
CHANGED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { SEED_RELATIONS } from "../constants.ts";
|
|
2
|
+
|
|
3
|
+
export type SkillArgumentValue = string | number | boolean;
|
|
4
|
+
export type SkillInputType = "string" | "number" | "boolean";
|
|
5
|
+
|
|
6
|
+
export interface SkillInputDefinition {
|
|
7
|
+
type: SkillInputType;
|
|
8
|
+
required?: boolean;
|
|
9
|
+
default?: SkillArgumentValue;
|
|
10
|
+
enum?: SkillArgumentValue[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface SkillDocBlueprint {
|
|
14
|
+
ref: string;
|
|
15
|
+
title: string;
|
|
16
|
+
body?: string;
|
|
17
|
+
subtype?: string;
|
|
18
|
+
labels?: string[];
|
|
19
|
+
extra?: Record<string, unknown>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface SkillRuleBlueprint {
|
|
23
|
+
ref: string;
|
|
24
|
+
title: string;
|
|
25
|
+
body?: string;
|
|
26
|
+
condition?: string;
|
|
27
|
+
action?: string;
|
|
28
|
+
severity?: "block" | "warn" | "info";
|
|
29
|
+
labels?: string[];
|
|
30
|
+
extra?: Record<string, unknown>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface SkillTaskBlueprint {
|
|
34
|
+
ref: string;
|
|
35
|
+
title: string;
|
|
36
|
+
body?: string;
|
|
37
|
+
dependsOn?: string[];
|
|
38
|
+
parent?: string;
|
|
39
|
+
labels?: string[];
|
|
40
|
+
extra?: Record<string, unknown>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface SkillBlueprints {
|
|
44
|
+
docs: SkillDocBlueprint[];
|
|
45
|
+
rules: SkillRuleBlueprint[];
|
|
46
|
+
tasks: SkillTaskBlueprint[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface SkillBlueprintLink {
|
|
50
|
+
from: string;
|
|
51
|
+
relation: string;
|
|
52
|
+
to: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface SkillDefinition {
|
|
56
|
+
version: 1;
|
|
57
|
+
inputs: Record<string, SkillInputDefinition>;
|
|
58
|
+
blueprints: SkillBlueprints;
|
|
59
|
+
links: SkillBlueprintLink[];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const MAX_INPUTS = 32;
|
|
63
|
+
const MAX_ENUM_VALUES = 32;
|
|
64
|
+
const MAX_BLUEPRINTS = 100;
|
|
65
|
+
const MAX_LINKS = 500;
|
|
66
|
+
const NAME_PATTERN = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/;
|
|
67
|
+
const PLACEHOLDER_PATTERN = /{{\s*([A-Za-z][A-Za-z0-9_-]{0,63})\s*}}/g;
|
|
68
|
+
const INPUT_TYPES = new Set<SkillInputType>(["string", "number", "boolean"]);
|
|
69
|
+
const RELATIONS = new Set<string>(SEED_RELATIONS);
|
|
70
|
+
|
|
71
|
+
function record(value: unknown, label: string): Record<string, unknown> {
|
|
72
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${label} must be an object`);
|
|
73
|
+
return value as Record<string, unknown>;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function array(value: unknown, label: string): unknown[] {
|
|
77
|
+
if (!Array.isArray(value)) throw new Error(`${label} must be an array`);
|
|
78
|
+
return value;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function string(value: unknown, label: string): string {
|
|
82
|
+
if (typeof value !== "string" || value.length === 0) throw new Error(`${label} must be a non-empty string`);
|
|
83
|
+
return value;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function validateArgumentValue(name: string, type: SkillInputType, value: unknown): SkillArgumentValue {
|
|
87
|
+
if (typeof value !== type || (type === "number" && !Number.isFinite(value))) {
|
|
88
|
+
throw new Error(`skill argument "${name}" must be a ${type}`);
|
|
89
|
+
}
|
|
90
|
+
return value as SkillArgumentValue;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function validateInputs(value: unknown): Record<string, SkillInputDefinition> {
|
|
94
|
+
const source = record(value ?? {}, "skill inputs");
|
|
95
|
+
const entries = Object.entries(source);
|
|
96
|
+
if (entries.length > MAX_INPUTS) throw new Error(`skill inputs exceed ${MAX_INPUTS}`);
|
|
97
|
+
const result: Record<string, SkillInputDefinition> = {};
|
|
98
|
+
for (const [name, raw] of entries) {
|
|
99
|
+
if (!NAME_PATTERN.test(name)) throw new Error(`invalid skill input name "${name}"`);
|
|
100
|
+
const input = record(raw, `skill input "${name}"`);
|
|
101
|
+
if (!INPUT_TYPES.has(input["type"] as SkillInputType)) throw new Error(`skill input "${name}" has unsupported type`);
|
|
102
|
+
const type = input["type"] as SkillInputType;
|
|
103
|
+
if (input["required"] !== undefined && typeof input["required"] !== "boolean") {
|
|
104
|
+
throw new Error(`skill input "${name}" required must be boolean`);
|
|
105
|
+
}
|
|
106
|
+
const normalized: SkillInputDefinition = { type };
|
|
107
|
+
if (input["required"] !== undefined) normalized.required = input["required"] as boolean;
|
|
108
|
+
if (input["default"] !== undefined) normalized.default = validateArgumentValue(name, type, input["default"]);
|
|
109
|
+
if (input["enum"] !== undefined) {
|
|
110
|
+
const values = array(input["enum"], `skill input "${name}" enum`);
|
|
111
|
+
if (values.length === 0 || values.length > MAX_ENUM_VALUES) throw new Error(`skill input "${name}" enum must contain 1-${MAX_ENUM_VALUES} values`);
|
|
112
|
+
normalized.enum = values.map((entry) => validateArgumentValue(name, type, entry));
|
|
113
|
+
if (normalized.default !== undefined && !normalized.enum.includes(normalized.default)) {
|
|
114
|
+
throw new Error(`skill input "${name}" default must be one of its enum values`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
result[name] = normalized;
|
|
118
|
+
}
|
|
119
|
+
return result;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function validateBlueprint<T extends { ref: string; title: string }>(value: unknown, kind: string): T {
|
|
123
|
+
const source = record(value, `skill ${kind} blueprint`);
|
|
124
|
+
const ref = string(source["ref"], `skill ${kind} blueprint ref`);
|
|
125
|
+
if (!NAME_PATTERN.test(ref)) throw new Error(`invalid skill blueprint ref "${ref}"`);
|
|
126
|
+
const title = string(source["title"], `skill ${kind} blueprint title`);
|
|
127
|
+
return { ...source, ref, title } as T;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function placeholders(value: unknown, result: Set<string> = new Set()): Set<string> {
|
|
131
|
+
if (typeof value === "string") {
|
|
132
|
+
for (const match of value.matchAll(PLACEHOLDER_PATTERN)) result.add(match[1]!);
|
|
133
|
+
} else if (Array.isArray(value)) {
|
|
134
|
+
for (const entry of value) placeholders(entry, result);
|
|
135
|
+
} else if (typeof value === "object" && value !== null) {
|
|
136
|
+
for (const entry of Object.values(value)) placeholders(entry, result);
|
|
137
|
+
}
|
|
138
|
+
return result;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function assertAcyclic(tasks: SkillTaskBlueprint[]): void {
|
|
142
|
+
const byRef = new Map(tasks.map((task) => [task.ref, task]));
|
|
143
|
+
const visiting = new Set<string>();
|
|
144
|
+
const visited = new Set<string>();
|
|
145
|
+
const visit = (ref: string): void => {
|
|
146
|
+
if (visiting.has(ref)) throw new Error(`skill task dependency cycle includes "${ref}"`);
|
|
147
|
+
if (visited.has(ref)) return;
|
|
148
|
+
visiting.add(ref);
|
|
149
|
+
for (const dependency of byRef.get(ref)?.dependsOn ?? []) visit(dependency);
|
|
150
|
+
visiting.delete(ref);
|
|
151
|
+
visited.add(ref);
|
|
152
|
+
};
|
|
153
|
+
for (const task of tasks) visit(task.ref);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function validateSkillDefinition(value: unknown): SkillDefinition {
|
|
157
|
+
const source = record(value, "skill definition");
|
|
158
|
+
if (source["version"] !== 1) throw new Error("skill definition version must be 1");
|
|
159
|
+
const inputs = validateInputs(source["inputs"]);
|
|
160
|
+
const rawBlueprints = record(source["blueprints"], "skill blueprints");
|
|
161
|
+
const docs = array(rawBlueprints["docs"] ?? [], "skill doc blueprints").map((entry) => validateBlueprint<SkillDocBlueprint>(entry, "doc"));
|
|
162
|
+
const rules = array(rawBlueprints["rules"] ?? [], "skill rule blueprints").map((entry) => validateBlueprint<SkillRuleBlueprint>(entry, "rule"));
|
|
163
|
+
const tasks = array(rawBlueprints["tasks"] ?? [], "skill task blueprints").map((entry) => validateBlueprint<SkillTaskBlueprint>(entry, "task"));
|
|
164
|
+
const all = [...docs, ...rules, ...tasks];
|
|
165
|
+
if (all.length === 0 || all.length > MAX_BLUEPRINTS) throw new Error(`skill blueprints must contain 1-${MAX_BLUEPRINTS} artifacts`);
|
|
166
|
+
const refs = new Set<string>();
|
|
167
|
+
for (const blueprint of all) {
|
|
168
|
+
if (refs.has(blueprint.ref)) throw new Error(`duplicate skill blueprint ref "${blueprint.ref}"`);
|
|
169
|
+
refs.add(blueprint.ref);
|
|
170
|
+
}
|
|
171
|
+
for (const task of tasks) {
|
|
172
|
+
if (task.dependsOn !== undefined && !Array.isArray(task.dependsOn)) throw new Error(`skill task "${task.ref}" dependsOn must be an array`);
|
|
173
|
+
for (const dependency of task.dependsOn ?? []) {
|
|
174
|
+
if (!tasks.some((candidate) => candidate.ref === dependency)) throw new Error(`unknown skill task dependency ref "${dependency}"`);
|
|
175
|
+
}
|
|
176
|
+
if (task.parent !== undefined && !tasks.some((candidate) => candidate.ref === task.parent)) {
|
|
177
|
+
throw new Error(`unknown skill task parent ref "${task.parent}"`);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
assertAcyclic(tasks);
|
|
181
|
+
for (const name of placeholders(all)) {
|
|
182
|
+
if (!(name in inputs)) throw new Error(`unknown skill input placeholder "${name}"`);
|
|
183
|
+
}
|
|
184
|
+
const links = array(source["links"] ?? [], "skill links").map((entry) => {
|
|
185
|
+
const link = record(entry, "skill link");
|
|
186
|
+
const from = string(link["from"], "skill link from");
|
|
187
|
+
const relation = string(link["relation"], "skill link relation");
|
|
188
|
+
const to = string(link["to"], "skill link to");
|
|
189
|
+
if (!refs.has(from)) throw new Error(`unknown skill blueprint ref "${from}"`);
|
|
190
|
+
if (!refs.has(to)) throw new Error(`unknown skill blueprint ref "${to}"`);
|
|
191
|
+
if (!RELATIONS.has(relation)) throw new Error(`unknown skill link relation "${relation}"`);
|
|
192
|
+
return { from, relation, to };
|
|
193
|
+
});
|
|
194
|
+
if (links.length > MAX_LINKS) throw new Error(`skill links exceed ${MAX_LINKS}`);
|
|
195
|
+
return { version: 1, inputs, blueprints: { docs, rules, tasks }, links };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function resolveSkillArguments(definition: SkillDefinition, value: unknown): Record<string, SkillArgumentValue> {
|
|
199
|
+
const source = record(value ?? {}, "skill arguments");
|
|
200
|
+
for (const name of Object.keys(source)) {
|
|
201
|
+
if (!(name in definition.inputs)) throw new Error(`unknown skill argument "${name}"`);
|
|
202
|
+
}
|
|
203
|
+
const result: Record<string, SkillArgumentValue> = {};
|
|
204
|
+
for (const [name, input] of Object.entries(definition.inputs)) {
|
|
205
|
+
const raw = source[name] ?? input.default;
|
|
206
|
+
if (raw === undefined) {
|
|
207
|
+
if (input.required) throw new Error(`missing required skill argument "${name}"`);
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
const normalized = validateArgumentValue(name, input.type, raw);
|
|
211
|
+
if (input.enum && !input.enum.includes(normalized)) {
|
|
212
|
+
throw new Error(`skill argument "${name}" must be one of: ${input.enum.join(", ")}`);
|
|
213
|
+
}
|
|
214
|
+
result[name] = normalized;
|
|
215
|
+
}
|
|
216
|
+
return result;
|
|
217
|
+
}
|
|
@@ -192,7 +192,7 @@ export function showSkill(artifacts: ArtifactStore, id: string): Artifact {
|
|
|
192
192
|
export function skillInvocation(artifacts: ArtifactStore, id: string): string {
|
|
193
193
|
const skill = requireKind(artifacts, id, "skill");
|
|
194
194
|
if (skill.subtype === "artifact-template") {
|
|
195
|
-
return `Create an artifact using Papyrus template "${skill.title}".\ntemplate_id: ${skill.id}\nAsk for or infer all required template fields, then call the skills
|
|
195
|
+
return `Create an artifact using Papyrus template "${skill.title}".\ntemplate_id: ${skill.id}\nAsk for or infer all required template fields, then call the skills domain tool instantiate action.`;
|
|
196
196
|
}
|
|
197
197
|
const trigger = typeof skill.extra["trigger"] === "string" ? skill.extra["trigger"] : "manual invocation";
|
|
198
198
|
const steps = Array.isArray(skill.extra["steps"]) ? skill.extra["steps"].filter((step): step is string => typeof step === "string") : [];
|
package/src/service.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { SERVICE_MAX_BODY_BYTES
|
|
1
|
+
import { SERVICE_MAX_BODY_BYTES } from "./constants.ts";
|
|
2
|
+
import { VERSION } from "./version.ts";
|
|
2
3
|
import { openDb } from "./db.ts";
|
|
3
4
|
import { SQLiteArtifactStore } from "./adapters/sqlite-artifact-store.ts";
|
|
4
5
|
import { SQLiteGateRunner } from "./adapters/sqlite-gate-runner.ts";
|
|
@@ -6,6 +7,7 @@ import type { CreateArtifactInput } from "./domain/artifact.ts";
|
|
|
6
7
|
import type { Checklist } from "./domain/checklist.ts";
|
|
7
8
|
import type { ArtifactStore } from "./ports/artifact-store.ts";
|
|
8
9
|
import type { GateRunner } from "./ports/gate-runner.ts";
|
|
10
|
+
import { projectTaskExecution } from "./task-execution.ts";
|
|
9
11
|
import { Tasks } from "./task-service.ts";
|
|
10
12
|
import {
|
|
11
13
|
createArtifactTemplate,
|
|
@@ -27,7 +29,7 @@ import {
|
|
|
27
29
|
transitionRule,
|
|
28
30
|
transitionSkill,
|
|
29
31
|
type DocumentRelation,
|
|
30
|
-
} from "./
|
|
32
|
+
} from "./domain-services.ts";
|
|
31
33
|
import { taskContext } from "./task-context.ts";
|
|
32
34
|
|
|
33
35
|
export const EXPECTED_OPERATION_NAMES = [
|
|
@@ -42,6 +44,7 @@ export const EXPECTED_OPERATION_NAMES = [
|
|
|
42
44
|
"tasks.create",
|
|
43
45
|
"tasks.list",
|
|
44
46
|
"tasks.graph",
|
|
47
|
+
"tasks.plan",
|
|
45
48
|
"tasks.show",
|
|
46
49
|
"tasks.start",
|
|
47
50
|
"tasks.complete",
|
|
@@ -131,7 +134,14 @@ function handlers(artifacts: ArtifactStore, gates: GateRunner, tasks: Tasks): Re
|
|
|
131
134
|
maxNodes: optionalNumber(input, "max_nodes") ?? optionalNumber(input, "maxNodes"),
|
|
132
135
|
}),
|
|
133
136
|
"graph.link": (input) => {
|
|
134
|
-
|
|
137
|
+
const from = string(input, "from");
|
|
138
|
+
const relation = string(input, "relation");
|
|
139
|
+
const to = string(input, "to");
|
|
140
|
+
if (relation === "depends_on" && artifacts.get(from)?.kind === "task" && artifacts.get(to)?.kind === "task") {
|
|
141
|
+
tasks.depend(from, to);
|
|
142
|
+
} else {
|
|
143
|
+
artifacts.link({ from, relation, to });
|
|
144
|
+
}
|
|
135
145
|
return { ok: true };
|
|
136
146
|
},
|
|
137
147
|
"graph.tree": (input) => artifacts.get(string(input, "id"), {
|
|
@@ -157,6 +167,7 @@ function handlers(artifacts: ArtifactStore, gates: GateRunner, tasks: Tasks): Re
|
|
|
157
167
|
}),
|
|
158
168
|
"tasks.list": (input) => tasks.list(taskFilter(input)),
|
|
159
169
|
"tasks.graph": (input) => tasks.graph(taskFilter(input)),
|
|
170
|
+
"tasks.plan": (input) => projectTaskExecution(tasks.graph(taskFilter(input))),
|
|
160
171
|
"tasks.show": (input) => tasks.show(string(input, "id")),
|
|
161
172
|
"tasks.start": (input) => tasks.transition(string(input, "id"), "start"),
|
|
162
173
|
"tasks.complete": (input) => tasks.completeAsync(string(input, "id")),
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { TASK_EXECUTION_MAX_DEGREE, TASK_EXECUTION_MAX_EDGES, TASK_EXECUTION_MAX_NODES } from "./constants.ts";
|
|
2
|
+
import type { TaskGraph } from "./task-service.ts";
|
|
3
|
+
|
|
4
|
+
export type TaskExecutionState = "done" | "active" | "ready" | "blocked" | "failed" | "invalid";
|
|
5
|
+
|
|
6
|
+
export interface TaskExecutionNode {
|
|
7
|
+
id: string;
|
|
8
|
+
title: string;
|
|
9
|
+
status: string;
|
|
10
|
+
state: TaskExecutionState;
|
|
11
|
+
layer: number | null;
|
|
12
|
+
prerequisiteIds: string[];
|
|
13
|
+
successorIds: string[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface TaskExecutionPlan {
|
|
17
|
+
nodes: TaskExecutionNode[];
|
|
18
|
+
layers: string[][];
|
|
19
|
+
cycleIds: string[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function executionState(status: string, invalid: boolean, prerequisitesDone: boolean): TaskExecutionState {
|
|
23
|
+
if (invalid) return "invalid";
|
|
24
|
+
if (status === "done" || status === "active" || status === "failed") return status;
|
|
25
|
+
if (status === "pending" && prerequisitesDone) return "ready";
|
|
26
|
+
return "blocked";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function assertBounds(graph: TaskGraph): void {
|
|
30
|
+
if (graph.nodes.length > TASK_EXECUTION_MAX_NODES) {
|
|
31
|
+
throw new Error(`task execution graph exceeds ${TASK_EXECUTION_MAX_NODES} nodes`);
|
|
32
|
+
}
|
|
33
|
+
for (const node of graph.nodes) {
|
|
34
|
+
if (node.dependencyIds.length > TASK_EXECUTION_MAX_DEGREE) {
|
|
35
|
+
throw new Error(`task "${node.task.id}" exceeds ${TASK_EXECUTION_MAX_DEGREE} prerequisites`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
const edgeCount = graph.nodes.reduce((count, node) => count + node.dependencyIds.length, 0);
|
|
39
|
+
if (edgeCount > TASK_EXECUTION_MAX_EDGES) {
|
|
40
|
+
throw new Error(`task execution graph exceeds ${TASK_EXECUTION_MAX_EDGES} dependency edges`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Build deterministic topological layers ordered by creation time and task ID. */
|
|
45
|
+
export function projectTaskExecution(graph: TaskGraph): TaskExecutionPlan {
|
|
46
|
+
assertBounds(graph);
|
|
47
|
+
const orderedNodes = [...graph.nodes].sort((left, right) =>
|
|
48
|
+
left.task.created_at.localeCompare(right.task.created_at) || left.task.id.localeCompare(right.task.id));
|
|
49
|
+
const byId = new Map(orderedNodes.map((node) => [node.task.id, node]));
|
|
50
|
+
const order = new Map(orderedNodes.map((node, index) => [node.task.id, index]));
|
|
51
|
+
const successors = new Map(orderedNodes.map((node) => [node.task.id, [] as string[]]));
|
|
52
|
+
const inDegree = new Map<string, number>();
|
|
53
|
+
|
|
54
|
+
for (const node of orderedNodes) {
|
|
55
|
+
const prerequisites = node.dependencyIds.filter((id) => byId.has(id));
|
|
56
|
+
inDegree.set(node.task.id, prerequisites.length);
|
|
57
|
+
for (const prerequisiteId of prerequisites) {
|
|
58
|
+
const dependentIds = successors.get(prerequisiteId)!;
|
|
59
|
+
if (dependentIds.length >= TASK_EXECUTION_MAX_DEGREE) {
|
|
60
|
+
throw new Error(`task "${prerequisiteId}" exceeds ${TASK_EXECUTION_MAX_DEGREE} successors`);
|
|
61
|
+
}
|
|
62
|
+
dependentIds.push(node.task.id);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let current = orderedNodes.filter((node) => inDegree.get(node.task.id) === 0).map((node) => node.task.id);
|
|
67
|
+
const layers: string[][] = [];
|
|
68
|
+
const processed = new Set<string>();
|
|
69
|
+
const layerById = new Map<string, number>();
|
|
70
|
+
while (current.length > 0) {
|
|
71
|
+
const layer = [...current].sort((left, right) => order.get(left)! - order.get(right)!);
|
|
72
|
+
layers.push(layer);
|
|
73
|
+
const next: string[] = [];
|
|
74
|
+
for (const id of layer) {
|
|
75
|
+
processed.add(id);
|
|
76
|
+
layerById.set(id, layers.length - 1);
|
|
77
|
+
for (const successorId of successors.get(id) ?? []) {
|
|
78
|
+
const remaining = inDegree.get(successorId)! - 1;
|
|
79
|
+
inDegree.set(successorId, remaining);
|
|
80
|
+
if (remaining === 0) next.push(successorId);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
current = next;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const cycleIds = orderedNodes.map((node) => node.task.id).filter((id) => !processed.has(id));
|
|
87
|
+
const cycleSet = new Set(cycleIds);
|
|
88
|
+
return {
|
|
89
|
+
layers,
|
|
90
|
+
cycleIds,
|
|
91
|
+
nodes: orderedNodes.map((node) => {
|
|
92
|
+
const prerequisiteIds = node.dependencyIds.filter((id) => byId.has(id));
|
|
93
|
+
const prerequisitesDone = prerequisiteIds.every((id) => byId.get(id)!.task.status === "done");
|
|
94
|
+
const state = executionState(node.task.status, cycleSet.has(node.task.id), prerequisitesDone);
|
|
95
|
+
return {
|
|
96
|
+
id: node.task.id,
|
|
97
|
+
title: node.task.title,
|
|
98
|
+
status: node.task.status,
|
|
99
|
+
state,
|
|
100
|
+
layer: layerById.get(node.task.id) ?? null,
|
|
101
|
+
prerequisiteIds,
|
|
102
|
+
successorIds: successors.get(node.task.id) ?? [],
|
|
103
|
+
};
|
|
104
|
+
}),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Reject a dependency edge when the prerequisite already reaches the dependent. */
|
|
109
|
+
export function assertDependencyEdgeAllowed(graph: TaskGraph, id: string, dependencyId: string): void {
|
|
110
|
+
assertBounds(graph);
|
|
111
|
+
if (id === dependencyId) throw new Error(`task "${id}" cannot depend on itself`);
|
|
112
|
+
const byId = new Map(graph.nodes.map((node) => [node.task.id, node]));
|
|
113
|
+
if (!byId.has(id) || !byId.has(dependencyId)) throw new Error("dependency endpoints must be present in the task graph");
|
|
114
|
+
|
|
115
|
+
const pending = [dependencyId];
|
|
116
|
+
const visited = new Set<string>();
|
|
117
|
+
while (pending.length > 0) {
|
|
118
|
+
const current = pending.pop()!;
|
|
119
|
+
if (current === id) throw new Error(`dependency cycle: "${id}" cannot depend on "${dependencyId}"`);
|
|
120
|
+
if (visited.has(current)) continue;
|
|
121
|
+
visited.add(current);
|
|
122
|
+
for (const prerequisiteId of byId.get(current)?.dependencyIds ?? []) pending.push(prerequisiteId);
|
|
123
|
+
}
|
|
124
|
+
}
|
package/src/task-graph-view.ts
CHANGED
|
@@ -1,7 +1,17 @@
|
|
|
1
1
|
import type { DisplayGraph, DisplayGraphEdge } from "./domain/display-graph.ts";
|
|
2
|
+
import { projectTaskExecution, type TaskExecutionState } from "./task-execution.ts";
|
|
2
3
|
import type { TaskGraph } from "./task-service.ts";
|
|
3
4
|
|
|
4
|
-
export type TaskGraphView = "dependencies" | "composition";
|
|
5
|
+
export type TaskGraphView = "execution" | "dependencies" | "composition";
|
|
6
|
+
|
|
7
|
+
const EXECUTION_GLYPHS: Record<TaskExecutionState, string> = {
|
|
8
|
+
done: "■",
|
|
9
|
+
active: "●",
|
|
10
|
+
ready: "◇",
|
|
11
|
+
blocked: "○",
|
|
12
|
+
failed: "▲",
|
|
13
|
+
invalid: "!",
|
|
14
|
+
};
|
|
5
15
|
|
|
6
16
|
export function projectTaskGraph(graph: TaskGraph, view: TaskGraphView): DisplayGraph {
|
|
7
17
|
const edges: DisplayGraphEdge[] = [];
|
|
@@ -14,7 +24,7 @@ export function projectTaskGraph(graph: TaskGraph, view: TaskGraphView): Display
|
|
|
14
24
|
};
|
|
15
25
|
|
|
16
26
|
for (const node of graph.nodes) {
|
|
17
|
-
if (view === "dependencies") {
|
|
27
|
+
if (view === "execution" || view === "dependencies") {
|
|
18
28
|
for (const dependencyId of node.dependencyIds) {
|
|
19
29
|
addEdge({ from: dependencyId, to: node.task.id, label: "unlocks" });
|
|
20
30
|
}
|
|
@@ -23,12 +33,21 @@ export function projectTaskGraph(graph: TaskGraph, view: TaskGraphView): Display
|
|
|
23
33
|
}
|
|
24
34
|
}
|
|
25
35
|
|
|
26
|
-
const connected =
|
|
36
|
+
const connected = view === "execution"
|
|
37
|
+
? new Set(graph.nodes.map((node) => node.task.id))
|
|
38
|
+
: new Set(edges.flatMap((edge) => [edge.from, edge.to]));
|
|
39
|
+
const nodes = view === "execution"
|
|
40
|
+
? projectTaskExecution(graph).nodes.map((node) => ({
|
|
41
|
+
id: node.id,
|
|
42
|
+
label: `${EXECUTION_GLYPHS[node.state]} ${node.title} · ${node.layer === null ? "no layer" : `layer ${node.layer + 1}`} · ${node.state}`,
|
|
43
|
+
status: node.state,
|
|
44
|
+
}))
|
|
45
|
+
: graph.nodes
|
|
46
|
+
.filter((node) => connected.has(node.task.id))
|
|
47
|
+
.map((node) => ({ id: node.task.id, label: node.task.title, status: node.task.status }));
|
|
27
48
|
return {
|
|
28
49
|
direction: "TD",
|
|
29
|
-
nodes
|
|
30
|
-
.filter((node) => connected.has(node.task.id))
|
|
31
|
-
.map((node) => ({ id: node.task.id, label: node.task.title, status: node.task.status })),
|
|
50
|
+
nodes,
|
|
32
51
|
edges: edges.filter((edge) => connected.has(edge.from) && connected.has(edge.to)),
|
|
33
52
|
};
|
|
34
53
|
}
|