@danypops/papyrus 0.1.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.
- package/README.md +139 -0
- package/extension/src/artifact-browser.ts +213 -0
- package/extension/src/artifact-format.ts +82 -0
- package/extension/src/beautiful-mermaid-renderer.ts +45 -0
- package/extension/src/docs.ts +48 -0
- package/extension/src/facade-tools.ts +209 -0
- package/extension/src/index.ts +354 -0
- package/extension/src/rules.ts +44 -0
- package/extension/src/service-client.ts +45 -0
- package/extension/src/skills.ts +60 -0
- package/extension/src/task-context.ts +1 -0
- package/extension/src/task-detail-format.ts +66 -0
- package/extension/src/task-detail-view.ts +111 -0
- package/extension/src/task-graph.ts +97 -0
- package/extension/src/task-widget.ts +49 -0
- package/extension/src/tasks.ts +258 -0
- package/package.json +43 -0
- package/src/adapters/sqlite-artifact-store.ts +64 -0
- package/src/adapters/sqlite-gate-runner.ts +16 -0
- package/src/cli.ts +71 -0
- package/src/client.ts +59 -0
- package/src/constants.ts +113 -0
- package/src/daemon-state.ts +59 -0
- package/src/daemon.ts +41 -0
- package/src/db.ts +138 -0
- package/src/domain/artifact.ts +56 -0
- package/src/domain/checklist.ts +70 -0
- package/src/domain/display-graph.ts +23 -0
- package/src/domain/gate.ts +11 -0
- package/src/facades.ts +215 -0
- package/src/ops.ts +336 -0
- package/src/ports/artifact-store.ts +19 -0
- package/src/ports/gate-runner.ts +6 -0
- package/src/ports/graph-renderer.ts +5 -0
- package/src/service.ts +292 -0
- package/src/task-context.ts +52 -0
- package/src/task-graph-view.ts +34 -0
- package/src/task-relationship-view.ts +39 -0
- package/src/task-service.ts +176 -0
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
export const PROOF_TYPES = ["file", "symbol", "code", "test", "command", "artifact", "url"] as const;
|
|
2
|
+
|
|
3
|
+
export type ProofType = typeof PROOF_TYPES[number];
|
|
4
|
+
|
|
5
|
+
export interface ProofReference {
|
|
6
|
+
type: ProofType;
|
|
7
|
+
target: string;
|
|
8
|
+
expect?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface ChecklistCriterion {
|
|
12
|
+
proof: ProofReference[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type Checklist = Record<string, ChecklistCriterion>;
|
|
16
|
+
|
|
17
|
+
export interface ChecklistEntry {
|
|
18
|
+
item: string;
|
|
19
|
+
proof: ProofReference[];
|
|
20
|
+
legacy: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
24
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function proofReference(value: unknown): ProofReference | undefined {
|
|
28
|
+
if (!isRecord(value) || !PROOF_TYPES.includes(value["type"] as ProofType)) return undefined;
|
|
29
|
+
if (typeof value["target"] !== "string" || value["target"].trim().length === 0) return undefined;
|
|
30
|
+
if (value["expect"] !== undefined && typeof value["expect"] !== "string") return undefined;
|
|
31
|
+
return {
|
|
32
|
+
type: value["type"] as ProofType,
|
|
33
|
+
target: value["target"],
|
|
34
|
+
...(typeof value["expect"] === "string" ? { expect: value["expect"] } : {}),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function validateChecklist(value: unknown): Checklist {
|
|
39
|
+
if (!isRecord(value)) throw new Error("checklist must be an item-to-proof map");
|
|
40
|
+
const checklist: Checklist = {};
|
|
41
|
+
for (const [item, criterion] of Object.entries(value)) {
|
|
42
|
+
if (item.trim().length === 0) throw new Error("checklist item must not be empty");
|
|
43
|
+
if (!isRecord(criterion) || !Array.isArray(criterion["proof"]) || criterion["proof"].length === 0) {
|
|
44
|
+
throw new Error(`checklist item "${item}" requires at least one proof reference`);
|
|
45
|
+
}
|
|
46
|
+
const proof = criterion["proof"].map(proofReference);
|
|
47
|
+
if (proof.some((reference) => reference === undefined)) {
|
|
48
|
+
throw new Error(`checklist item "${item}" requires a typed, non-empty proof target`);
|
|
49
|
+
}
|
|
50
|
+
checklist[item] = { proof: proof as ProofReference[] };
|
|
51
|
+
}
|
|
52
|
+
return checklist;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function checklistEntries(value: unknown): ChecklistEntry[] {
|
|
56
|
+
if (Array.isArray(value)) {
|
|
57
|
+
return value.flatMap((item) => typeof item === "string"
|
|
58
|
+
? [{ item, proof: [], legacy: true }]
|
|
59
|
+
: isRecord(item) && typeof item["title"] === "string"
|
|
60
|
+
? [{ item: item["title"], proof: [], legacy: true }]
|
|
61
|
+
: []);
|
|
62
|
+
}
|
|
63
|
+
if (!isRecord(value)) return [];
|
|
64
|
+
return Object.entries(value).map(([item, criterion]) => {
|
|
65
|
+
const references = isRecord(criterion) && Array.isArray(criterion["proof"])
|
|
66
|
+
? criterion["proof"].map(proofReference).filter((proof): proof is ProofReference => proof !== undefined)
|
|
67
|
+
: [];
|
|
68
|
+
return { item, proof: references, legacy: false };
|
|
69
|
+
});
|
|
70
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export type GraphDirection = "TD" | "LR";
|
|
2
|
+
|
|
3
|
+
export interface DisplayGraphNode {
|
|
4
|
+
id: string;
|
|
5
|
+
label: string;
|
|
6
|
+
status?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface DisplayGraphEdge {
|
|
10
|
+
from: string;
|
|
11
|
+
to: string;
|
|
12
|
+
label?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface DisplayGraph {
|
|
16
|
+
direction: GraphDirection;
|
|
17
|
+
nodes: DisplayGraphNode[];
|
|
18
|
+
edges: DisplayGraphEdge[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface RenderedGraph {
|
|
22
|
+
lines: string[];
|
|
23
|
+
}
|
package/src/facades.ts
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import type { Artifact, CreateArtifactInput } from "./domain/artifact.ts";
|
|
2
|
+
import type { ArtifactStore } from "./ports/artifact-store.ts";
|
|
3
|
+
|
|
4
|
+
export interface ListFilter {
|
|
5
|
+
status?: string;
|
|
6
|
+
text?: string;
|
|
7
|
+
limit?: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function requireKind(artifacts: ArtifactStore, id: string, kind: string): Artifact {
|
|
11
|
+
const artifact = artifacts.get(id);
|
|
12
|
+
if (!artifact) throw new Error(`${kind} artifact "${id}" not found`);
|
|
13
|
+
if (artifact.kind !== kind) throw new Error(`artifact "${id}" is not a ${kind}`);
|
|
14
|
+
return artifact;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface CreateDocumentInput {
|
|
18
|
+
title: string;
|
|
19
|
+
body?: string;
|
|
20
|
+
subtype?: string;
|
|
21
|
+
labels?: string[];
|
|
22
|
+
extra?: Record<string, unknown>;
|
|
23
|
+
templateId?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type DocumentTransition = "activate" | "archive" | "reopen";
|
|
27
|
+
export type DocumentRelation = "references" | "documents" | "supersedes" | "relates_to" | "contains" | "part_of";
|
|
28
|
+
|
|
29
|
+
const DOCUMENT_TRANSITIONS: Record<DocumentTransition, { from: string[]; to: string }> = {
|
|
30
|
+
activate: { from: ["draft"], to: "active" },
|
|
31
|
+
archive: { from: ["draft", "active"], to: "archived" },
|
|
32
|
+
reopen: { from: ["archived"], to: "draft" },
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export function createDocument(artifacts: ArtifactStore, input: CreateDocumentInput): Artifact {
|
|
36
|
+
return artifacts.create({
|
|
37
|
+
kind: "doc",
|
|
38
|
+
title: input.title,
|
|
39
|
+
body: input.body,
|
|
40
|
+
subtype: input.subtype,
|
|
41
|
+
labels: input.labels,
|
|
42
|
+
extra: input.extra,
|
|
43
|
+
templateId: input.templateId,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function listDocuments(artifacts: ArtifactStore, filter: ListFilter): Artifact[] {
|
|
48
|
+
return artifacts.query({ kind: "doc", ...filter });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function showDocument(artifacts: ArtifactStore, id: string): Artifact {
|
|
52
|
+
requireKind(artifacts, id, "doc");
|
|
53
|
+
return artifacts.get(id, { tree: true })!;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function transitionDocument(artifacts: ArtifactStore, id: string, action: DocumentTransition): Artifact {
|
|
57
|
+
const document = requireKind(artifacts, id, "doc");
|
|
58
|
+
const transition = DOCUMENT_TRANSITIONS[action];
|
|
59
|
+
if (!transition.from.includes(document.status)) throw new Error(`cannot ${action} document from ${document.status}`);
|
|
60
|
+
return artifacts.setStatus(id, transition.to)!;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function linkDocument(artifacts: ArtifactStore, id: string, relation: DocumentRelation, targetId: string): Artifact {
|
|
64
|
+
requireKind(artifacts, id, "doc");
|
|
65
|
+
if (!artifacts.get(targetId)) throw new Error(`target artifact "${targetId}" not found`);
|
|
66
|
+
artifacts.link({ from: id, relation, to: targetId });
|
|
67
|
+
return showDocument(artifacts, id);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface CreateRuleInput {
|
|
71
|
+
title: string;
|
|
72
|
+
body?: string;
|
|
73
|
+
condition?: string;
|
|
74
|
+
action?: string;
|
|
75
|
+
severity?: "block" | "warn" | "info";
|
|
76
|
+
labels?: string[];
|
|
77
|
+
extra?: Record<string, unknown>;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export type RuleTransition = "enable" | "disable";
|
|
81
|
+
|
|
82
|
+
export function createRule(artifacts: ArtifactStore, input: CreateRuleInput): Artifact {
|
|
83
|
+
return artifacts.create({
|
|
84
|
+
kind: "rule",
|
|
85
|
+
title: input.title,
|
|
86
|
+
body: input.body,
|
|
87
|
+
labels: input.labels,
|
|
88
|
+
extra: {
|
|
89
|
+
...(input.extra ?? {}),
|
|
90
|
+
...(input.condition ? { condition: input.condition } : {}),
|
|
91
|
+
...(input.action ? { action: input.action } : {}),
|
|
92
|
+
severity: input.severity ?? "info",
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function listRules(artifacts: ArtifactStore, filter: ListFilter): Artifact[] {
|
|
98
|
+
return artifacts.query({ kind: "rule", ...filter });
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function showRule(artifacts: ArtifactStore, id: string): Artifact {
|
|
102
|
+
requireKind(artifacts, id, "rule");
|
|
103
|
+
return artifacts.get(id, { tree: true })!;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function previewRule(artifacts: ArtifactStore, id: string): string {
|
|
107
|
+
const rule = requireKind(artifacts, id, "rule");
|
|
108
|
+
const condition = typeof rule.extra["condition"] === "string" ? ` (when: ${rule.extra["condition"]})` : "";
|
|
109
|
+
const action = rule.body || (typeof rule.extra["action"] === "string" ? rule.extra["action"] : "");
|
|
110
|
+
return `• ${rule.title}${condition}\n ${action}`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function transitionRule(artifacts: ArtifactStore, id: string, action: RuleTransition): Artifact {
|
|
114
|
+
const rule = requireKind(artifacts, id, "rule");
|
|
115
|
+
const expected = action === "enable" ? "deprecated" : "active";
|
|
116
|
+
const target = action === "enable" ? "active" : "deprecated";
|
|
117
|
+
if (rule.status !== expected) throw new Error(`cannot ${action} rule from ${rule.status}`);
|
|
118
|
+
return artifacts.setStatus(id, target)!;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function gateTaskWithRule(artifacts: ArtifactStore, ruleId: string, taskId: string): Artifact {
|
|
122
|
+
requireKind(artifacts, ruleId, "rule");
|
|
123
|
+
requireKind(artifacts, taskId, "task");
|
|
124
|
+
artifacts.link({ from: ruleId, relation: "gates", to: taskId });
|
|
125
|
+
return showRule(artifacts, ruleId);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export interface CreateSkillInput {
|
|
129
|
+
title: string;
|
|
130
|
+
body?: string;
|
|
131
|
+
trigger?: string;
|
|
132
|
+
steps?: string[];
|
|
133
|
+
tools?: string[];
|
|
134
|
+
labels?: string[];
|
|
135
|
+
extra?: Record<string, unknown>;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export interface CreateArtifactTemplateInput {
|
|
139
|
+
title: string;
|
|
140
|
+
targetKind: string;
|
|
141
|
+
defaults?: Record<string, unknown>;
|
|
142
|
+
required?: string[];
|
|
143
|
+
body?: string;
|
|
144
|
+
labels?: string[];
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export type SkillTransition = "enable" | "disable";
|
|
148
|
+
|
|
149
|
+
export function createSkill(artifacts: ArtifactStore, input: CreateSkillInput): Artifact {
|
|
150
|
+
return artifacts.create({
|
|
151
|
+
kind: "skill",
|
|
152
|
+
title: input.title,
|
|
153
|
+
body: input.body,
|
|
154
|
+
labels: input.labels,
|
|
155
|
+
extra: {
|
|
156
|
+
...(input.extra ?? {}),
|
|
157
|
+
...(input.trigger ? { trigger: input.trigger } : {}),
|
|
158
|
+
...(input.steps ? { steps: input.steps } : {}),
|
|
159
|
+
...(input.tools ? { tools: input.tools } : {}),
|
|
160
|
+
},
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function createArtifactTemplate(artifacts: ArtifactStore, input: CreateArtifactTemplateInput): Artifact {
|
|
165
|
+
return artifacts.create({
|
|
166
|
+
kind: "skill",
|
|
167
|
+
subtype: "artifact-template",
|
|
168
|
+
title: input.title,
|
|
169
|
+
body: input.body,
|
|
170
|
+
labels: input.labels,
|
|
171
|
+
extra: {
|
|
172
|
+
targetKind: input.targetKind,
|
|
173
|
+
defaults: input.defaults ?? {},
|
|
174
|
+
required: input.required ?? ["title"],
|
|
175
|
+
},
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function instantiateTemplate(artifacts: ArtifactStore, templateId: string, input: CreateArtifactInput): Artifact {
|
|
180
|
+
return artifacts.create({ ...input, templateId });
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function listSkills(artifacts: ArtifactStore, filter: ListFilter): Artifact[] {
|
|
184
|
+
return artifacts.query({ kind: "skill", ...filter });
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function showSkill(artifacts: ArtifactStore, id: string): Artifact {
|
|
188
|
+
requireKind(artifacts, id, "skill");
|
|
189
|
+
return artifacts.get(id, { tree: true })!;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function skillInvocation(artifacts: ArtifactStore, id: string): string {
|
|
193
|
+
const skill = requireKind(artifacts, id, "skill");
|
|
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 facade instantiate action.`;
|
|
196
|
+
}
|
|
197
|
+
const trigger = typeof skill.extra["trigger"] === "string" ? skill.extra["trigger"] : "manual invocation";
|
|
198
|
+
const steps = Array.isArray(skill.extra["steps"]) ? skill.extra["steps"].filter((step): step is string => typeof step === "string") : [];
|
|
199
|
+
const tools = Array.isArray(skill.extra["tools"]) ? skill.extra["tools"].filter((tool): tool is string => typeof tool === "string") : [];
|
|
200
|
+
return [
|
|
201
|
+
`Apply Papyrus skill "${skill.title}" (${skill.id}).`,
|
|
202
|
+
`Trigger: ${trigger}`,
|
|
203
|
+
...(skill.body ? [`Context: ${skill.body}`] : []),
|
|
204
|
+
...(steps.length ? ["Steps:", ...steps.map((step, index) => `${index + 1}. ${step}`)] : []),
|
|
205
|
+
...(tools.length ? [`Tools: ${tools.join(", ")}`] : []),
|
|
206
|
+
].join("\n");
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function transitionSkill(artifacts: ArtifactStore, id: string, action: SkillTransition): Artifact {
|
|
210
|
+
const skill = requireKind(artifacts, id, "skill");
|
|
211
|
+
const expected = action === "enable" ? "deprecated" : "active";
|
|
212
|
+
const target = action === "enable" ? "active" : "deprecated";
|
|
213
|
+
if (skill.status !== expected) throw new Error(`cannot ${action} skill from ${skill.status}`);
|
|
214
|
+
return artifacts.setStatus(id, target)!;
|
|
215
|
+
}
|
package/src/ops.ts
ADDED
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ops.ts — typed operations over the Papyrus DB.
|
|
3
|
+
* Enforces the schema protocol (kinds, statuses, relations) via FK + app validation.
|
|
4
|
+
*/
|
|
5
|
+
import { createRequire } from "node:module";
|
|
6
|
+
import { exec } from "node:child_process";
|
|
7
|
+
import type { Db } from "./db.ts";
|
|
8
|
+
import { inTransaction } from "./db.ts";
|
|
9
|
+
import type { Artifact, CreateArtifactInput } from "./domain/artifact.ts";
|
|
10
|
+
import type { Gate, GateResult } from "./domain/gate.ts";
|
|
11
|
+
export type { Artifact } from "./domain/artifact.ts";
|
|
12
|
+
export type { Gate, GateResult } from "./domain/gate.ts";
|
|
13
|
+
export type CreateInput = CreateArtifactInput;
|
|
14
|
+
import {
|
|
15
|
+
DEFAULT_GRAPH_DEPTH,
|
|
16
|
+
DEFAULT_GRAPH_MAX_NODES,
|
|
17
|
+
MAX_GRAPH_DEPTH,
|
|
18
|
+
MAX_GRAPH_NODES,
|
|
19
|
+
GATE_COMMAND_TIMEOUT_MS,
|
|
20
|
+
GATE_TEST_TIMEOUT_MS,
|
|
21
|
+
GATE_OUTPUT_LIMIT,
|
|
22
|
+
GATE_MAX_BUFFER_BYTES,
|
|
23
|
+
} from "./constants.ts";
|
|
24
|
+
|
|
25
|
+
const require_ = createRequire(import.meta.url);
|
|
26
|
+
|
|
27
|
+
interface ResolvedCreateInput extends CreateInput {
|
|
28
|
+
kind: string;
|
|
29
|
+
title: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
33
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Merge object defaults recursively; explicit arrays and scalar values replace defaults. */
|
|
37
|
+
function deepMerge(base: unknown, override: unknown): unknown {
|
|
38
|
+
if (!isRecord(base) || !isRecord(override)) return override === undefined ? base : override;
|
|
39
|
+
const merged: Record<string, unknown> = { ...base };
|
|
40
|
+
for (const [key, value] of Object.entries(override)) {
|
|
41
|
+
if (value === undefined) continue;
|
|
42
|
+
merged[key] = key in merged ? deepMerge(merged[key], value) : value;
|
|
43
|
+
}
|
|
44
|
+
return merged;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function valueAtPath(value: unknown, path: string): unknown {
|
|
48
|
+
return path.split(".").reduce<unknown>((current, segment) =>
|
|
49
|
+
isRecord(current) ? current[segment] : undefined, value);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function isPresent(value: unknown): boolean {
|
|
53
|
+
return value !== undefined && value !== null && value !== "";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function resolveCreateInput(db: Db, input: CreateInput): ResolvedCreateInput {
|
|
57
|
+
if (!input.templateId) {
|
|
58
|
+
if (!input.kind) throw new Error("kind is required");
|
|
59
|
+
if (!input.title) throw new Error("title is required");
|
|
60
|
+
return input as ResolvedCreateInput;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const template = getArtifact(db, input.templateId);
|
|
64
|
+
if (!template) throw new Error(`template "${input.templateId}" not found`);
|
|
65
|
+
if (template.kind !== "skill" || template.subtype !== "artifact-template") {
|
|
66
|
+
throw new Error(`artifact "${input.templateId}" is not an artifact template`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const targetKind = template.extra["targetKind"];
|
|
70
|
+
if (typeof targetKind !== "string" || targetKind.length === 0) {
|
|
71
|
+
throw new Error(`template "${input.templateId}" has no targetKind`);
|
|
72
|
+
}
|
|
73
|
+
if (input.kind && input.kind !== targetKind) {
|
|
74
|
+
throw new Error(`template "${input.templateId}" targets kind "${targetKind}", not "${input.kind}"`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const defaults = isRecord(template.extra["defaults"]) ? template.extra["defaults"] : {};
|
|
78
|
+
const { templateId: _templateId, ...overrides } = input;
|
|
79
|
+
const merged = deepMerge(defaults, overrides) as CreateInput;
|
|
80
|
+
merged.kind = targetKind;
|
|
81
|
+
|
|
82
|
+
const required = Array.isArray(template.extra["required"])
|
|
83
|
+
? template.extra["required"].filter((field): field is string => typeof field === "string")
|
|
84
|
+
: ["title"];
|
|
85
|
+
for (const field of required) {
|
|
86
|
+
if (!isPresent(valueAtPath(merged, field))) {
|
|
87
|
+
throw new Error(`missing required template field "${field}"`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (!merged.title) throw new Error("title is required");
|
|
91
|
+
return merged as ResolvedCreateInput;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function slugify(s: string): string {
|
|
95
|
+
return s
|
|
96
|
+
.toLowerCase()
|
|
97
|
+
.replace(/[^a-z0-9\s-]/g, "")
|
|
98
|
+
.trim()
|
|
99
|
+
.replace(/\s+/g, "-")
|
|
100
|
+
.slice(0, 60) + "-" + Math.random().toString(36).slice(2, 6);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function defaultStatusFor(db: Db, kind: string): string {
|
|
104
|
+
// First-inserted status per kind (seed order defines the default)
|
|
105
|
+
const row = db.prepare("SELECT name FROM statuses WHERE kind = ? ORDER BY rowid LIMIT 1").get(kind) as { name: string } | null;
|
|
106
|
+
return row?.name ?? "draft";
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function rowToArtifact(row: Record<string, unknown>): Artifact {
|
|
110
|
+
return {
|
|
111
|
+
id: row["id"] as string,
|
|
112
|
+
kind: row["kind"] as string,
|
|
113
|
+
title: row["title"] as string,
|
|
114
|
+
status: row["status"] as string,
|
|
115
|
+
subtype: (row["subtype"] as string) ?? "",
|
|
116
|
+
body: (row["body"] as string) ?? "",
|
|
117
|
+
labels: JSON.parse((row["labels"] as string) ?? "[]"),
|
|
118
|
+
extra: JSON.parse((row["extra"] as string) ?? "{}"),
|
|
119
|
+
created_at: row["created_at"] as string,
|
|
120
|
+
updated_at: row["updated_at"] as string,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function createArtifact(db: Db, input: CreateInput): Artifact {
|
|
125
|
+
const resolved = resolveCreateInput(db, input);
|
|
126
|
+
const id = resolved.id ?? slugify(resolved.title);
|
|
127
|
+
const status = resolved.status ?? defaultStatusFor(db, resolved.kind);
|
|
128
|
+
const now = new Date().toISOString();
|
|
129
|
+
const labels = JSON.stringify(resolved.labels ?? []);
|
|
130
|
+
const extra = JSON.stringify(resolved.extra ?? {});
|
|
131
|
+
const subtype = resolved.subtype ?? "";
|
|
132
|
+
inTransaction(db, () => {
|
|
133
|
+
const stmt = db.prepare(
|
|
134
|
+
"INSERT INTO artifacts (id, kind, title, status, subtype, body, labels, extra, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
135
|
+
);
|
|
136
|
+
stmt.run(id, resolved.kind, resolved.title, status, subtype, resolved.body ?? "", labels, extra, now, now);
|
|
137
|
+
});
|
|
138
|
+
return getArtifact(db, id)!;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function getArtifact(db: Db, id: string, opts?: { tree?: boolean; depth?: number; maxNodes?: number }): Artifact | null {
|
|
142
|
+
const row = db.prepare("SELECT * FROM artifacts WHERE id = ?").get(id) as Record<string, unknown> | null;
|
|
143
|
+
if (!row) return null;
|
|
144
|
+
const art = rowToArtifact(row);
|
|
145
|
+
if (opts?.tree) {
|
|
146
|
+
const depthLimit = Math.min(MAX_GRAPH_DEPTH, Math.max(0, Math.floor(opts.depth ?? DEFAULT_GRAPH_DEPTH)));
|
|
147
|
+
const nodeLimit = Math.min(MAX_GRAPH_NODES, Math.max(1, Math.floor(opts.maxNodes ?? DEFAULT_GRAPH_MAX_NODES)));
|
|
148
|
+
const queue: Array<{ id: string; depth: number }> = [{ id, depth: 0 }];
|
|
149
|
+
const allEdges = db.prepare('SELECT from_id AS "from", relation, to_id AS "to" FROM edges').all() as { from: string; relation: string; to: string }[];
|
|
150
|
+
const reachable = new Set<string>([id]);
|
|
151
|
+
const adj = new Map<string, { from: string; relation: string; to: string }[]>();
|
|
152
|
+
for (const edge of allEdges) {
|
|
153
|
+
if (!adj.has(edge.from)) adj.set(edge.from, []);
|
|
154
|
+
adj.get(edge.from)!.push(edge);
|
|
155
|
+
if (!adj.has(edge.to)) adj.set(edge.to, []);
|
|
156
|
+
adj.get(edge.to)!.push(edge);
|
|
157
|
+
}
|
|
158
|
+
while (queue.length > 0 && reachable.size < nodeLimit) {
|
|
159
|
+
const current = queue.shift()!;
|
|
160
|
+
if (current.depth >= depthLimit) continue;
|
|
161
|
+
for (const edge of adj.get(current.id) ?? []) {
|
|
162
|
+
const other = edge.from === current.id ? edge.to : edge.from;
|
|
163
|
+
if (reachable.has(other)) continue;
|
|
164
|
+
if (reachable.size >= nodeLimit) break;
|
|
165
|
+
reachable.add(other);
|
|
166
|
+
queue.push({ id: other, depth: current.depth + 1 });
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
art.edges = allEdges.filter((edge) => reachable.has(edge.from) && reachable.has(edge.to));
|
|
170
|
+
}
|
|
171
|
+
return art;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function queryArtifacts(db: Db, filter: {
|
|
175
|
+
kind?: string;
|
|
176
|
+
status?: string;
|
|
177
|
+
text?: string;
|
|
178
|
+
labels?: string[];
|
|
179
|
+
limit?: number;
|
|
180
|
+
}): Artifact[] {
|
|
181
|
+
let sql = "SELECT * FROM artifacts";
|
|
182
|
+
const conditions: string[] = [];
|
|
183
|
+
const params: unknown[] = [];
|
|
184
|
+
if (filter.kind) { conditions.push("kind = ?"); params.push(filter.kind); }
|
|
185
|
+
if (filter.status) { conditions.push("status = ?"); params.push(filter.status); }
|
|
186
|
+
if (filter.text) { conditions.push("(title LIKE ? OR body LIKE ?)"); params.push(`%${filter.text}%`, `%${filter.text}%`); }
|
|
187
|
+
if (conditions.length) sql += " WHERE " + conditions.join(" AND ");
|
|
188
|
+
sql += " ORDER BY updated_at DESC";
|
|
189
|
+
if (filter.limit) sql += ` LIMIT ${Math.floor(filter.limit)}`;
|
|
190
|
+
const rows = db.prepare(sql).all(...params) as Record<string, unknown>[];
|
|
191
|
+
return rows.map(rowToArtifact);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function linkArtifacts(db: Db, fromId: string, relation: string, toId: string): void {
|
|
195
|
+
const fromArt = getArtifact(db, fromId);
|
|
196
|
+
const toArt = getArtifact(db, toId);
|
|
197
|
+
if (!fromArt || !toArt) throw new Error("artifact not found");
|
|
198
|
+
// Relation name must be registered (FK on edges.relation enforces this too)
|
|
199
|
+
const allowed = db.prepare("SELECT 1 FROM relation_names WHERE name = ?").get(relation);
|
|
200
|
+
if (!allowed) throw new Error(`unknown relation "${relation}" — register it first`);
|
|
201
|
+
inTransaction(db, () => {
|
|
202
|
+
db.prepare("INSERT OR IGNORE INTO edges (from_id, relation, to_id) VALUES (?, ?, ?)").run(fromId, relation, toId);
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function updateStatus(db: Db, id: string, status: string): Artifact | null {
|
|
207
|
+
const art = getArtifact(db, id);
|
|
208
|
+
if (!art) return null;
|
|
209
|
+
// Validate status is registered for this kind
|
|
210
|
+
const allowed = db.prepare("SELECT 1 FROM statuses WHERE kind = ? AND name = ?").get(art.kind, status);
|
|
211
|
+
if (!allowed) throw new Error(`status "${status}" not registered for kind "${art.kind}"`);
|
|
212
|
+
const now = new Date().toISOString();
|
|
213
|
+
inTransaction(db, () => {
|
|
214
|
+
db.prepare("UPDATE artifacts SET status = ?, updated_at = ? WHERE id = ?").run(status, now, id);
|
|
215
|
+
});
|
|
216
|
+
return getArtifact(db, id);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function updateExtra(db: Db, id: string, extra: Record<string, unknown>): Artifact | null {
|
|
220
|
+
if (!getArtifact(db, id)) return null;
|
|
221
|
+
const now = new Date().toISOString();
|
|
222
|
+
inTransaction(db, () => {
|
|
223
|
+
db.prepare("UPDATE artifacts SET extra = ?, updated_at = ? WHERE id = ?").run(JSON.stringify(extra), now, id);
|
|
224
|
+
});
|
|
225
|
+
return getArtifact(db, id);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Active rules with inject metadata — for before_agent_start system prompt injection. */
|
|
229
|
+
export function injectableRules(db: Db): Array<{ id: string; title: string; body: string; extra: Record<string, unknown> }> {
|
|
230
|
+
const rows = db.prepare("SELECT * FROM artifacts WHERE kind = 'rule' AND status = 'active' ORDER BY updated_at DESC").all() as Record<string, unknown>[];
|
|
231
|
+
return rows.map((row) => {
|
|
232
|
+
const art = rowToArtifact(row);
|
|
233
|
+
return { id: art.id, title: art.title, body: art.body, extra: art.extra };
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function runGates(db: Db, artifactId: string): GateResult[] {
|
|
238
|
+
const art = getArtifact(db, artifactId);
|
|
239
|
+
if (!art) throw new Error("artifact not found");
|
|
240
|
+
const gates = (art.extra["gates"] as Gate[]) ?? [];
|
|
241
|
+
return gates.map((gate) => {
|
|
242
|
+
switch (gate.type) {
|
|
243
|
+
case "file-exists": {
|
|
244
|
+
const { existsSync } = require_("node:fs");
|
|
245
|
+
const exists = existsSync(gate.target);
|
|
246
|
+
return { gate, passed: exists, output: exists ? "exists" : "not found" };
|
|
247
|
+
}
|
|
248
|
+
case "contains": {
|
|
249
|
+
const { readFileSync } = require_("node:fs");
|
|
250
|
+
try {
|
|
251
|
+
const content = readFileSync(gate.target, "utf-8");
|
|
252
|
+
const found = gate.expect ? content.includes(gate.expect) : content.length > 0;
|
|
253
|
+
return { gate, passed: found, output: found ? "found" : `"${gate.expect ?? ""}" not found` };
|
|
254
|
+
} catch {
|
|
255
|
+
return { gate, passed: false, output: "file not readable" };
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
case "command": {
|
|
259
|
+
const { execSync } = require_("node:child_process");
|
|
260
|
+
try {
|
|
261
|
+
const output = execSync(gate.target, { encoding: "utf-8", timeout: GATE_COMMAND_TIMEOUT_MS, stdio: ["pipe", "pipe", "pipe"] }).trim();
|
|
262
|
+
const passed = gate.expect ? output.includes(gate.expect) : true;
|
|
263
|
+
return { gate, passed, output: output.slice(0, GATE_OUTPUT_LIMIT) };
|
|
264
|
+
} catch (e) {
|
|
265
|
+
return { gate, passed: false, output: e instanceof Error ? e.message.slice(0, GATE_OUTPUT_LIMIT) : "command failed" };
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
case "test": {
|
|
269
|
+
const { execSync } = require_("node:child_process");
|
|
270
|
+
try {
|
|
271
|
+
execSync(`npx vitest run ${gate.target} --reporter=dot`, { encoding: "utf-8", timeout: GATE_TEST_TIMEOUT_MS, stdio: ["pipe", "pipe", "pipe"] });
|
|
272
|
+
return { gate, passed: true, output: "tests passed" };
|
|
273
|
+
} catch (e) {
|
|
274
|
+
return { gate, passed: false, output: e instanceof Error ? e.message.slice(0, GATE_OUTPUT_LIMIT) : "tests failed" };
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
default:
|
|
278
|
+
return { gate, passed: false, output: `unknown gate type: ${String(gate.type)}` };
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function executeGateCommand(command: string, timeout: number): Promise<{ passed: boolean; output: string }> {
|
|
284
|
+
return new Promise((resolve) => {
|
|
285
|
+
exec(command, { encoding: "utf8", timeout, maxBuffer: GATE_MAX_BUFFER_BYTES }, (error, stdout, stderr) => {
|
|
286
|
+
const output = `${stdout}${stderr}`.trim().slice(0, GATE_OUTPUT_LIMIT);
|
|
287
|
+
resolve({
|
|
288
|
+
passed: error === null,
|
|
289
|
+
output: output || (error ? error.message.slice(0, GATE_OUTPUT_LIMIT) : "ok"),
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function runNonProcessGate(gate: Gate): GateResult {
|
|
296
|
+
if (gate.type === "file-exists") {
|
|
297
|
+
const { existsSync } = require_("node:fs");
|
|
298
|
+
const exists = existsSync(gate.target);
|
|
299
|
+
return { gate, passed: exists, output: exists ? "exists" : "not found" };
|
|
300
|
+
}
|
|
301
|
+
if (gate.type === "contains") {
|
|
302
|
+
const { readFileSync } = require_("node:fs");
|
|
303
|
+
try {
|
|
304
|
+
const content = readFileSync(gate.target, "utf-8");
|
|
305
|
+
const found = gate.expect ? content.includes(gate.expect) : content.length > 0;
|
|
306
|
+
return { gate, passed: found, output: found ? "found" : `"${gate.expect ?? ""}" not found` };
|
|
307
|
+
} catch {
|
|
308
|
+
return { gate, passed: false, output: "file not readable" };
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
return { gate, passed: false, output: `unknown gate type: ${String(gate.type)}` };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** Gate runner for daemon request paths; subprocess gates never block the event loop. */
|
|
315
|
+
export async function runGatesAsync(db: Db, artifactId: string): Promise<GateResult[]> {
|
|
316
|
+
const art = getArtifact(db, artifactId);
|
|
317
|
+
if (!art) throw new Error("artifact not found");
|
|
318
|
+
const gates = (art.extra["gates"] as Gate[]) ?? [];
|
|
319
|
+
const results: GateResult[] = [];
|
|
320
|
+
for (const gate of gates) {
|
|
321
|
+
if (gate.type === "command" || gate.type === "test") {
|
|
322
|
+
const command = gate.type === "test" ? `npx vitest run ${gate.target} --reporter=dot` : gate.target;
|
|
323
|
+
const timeout = gate.type === "test" ? GATE_TEST_TIMEOUT_MS : GATE_COMMAND_TIMEOUT_MS;
|
|
324
|
+
const executed = await executeGateCommand(command, timeout);
|
|
325
|
+
results.push({
|
|
326
|
+
gate,
|
|
327
|
+
passed: executed.passed && (gate.expect ? executed.output.includes(gate.expect) : true),
|
|
328
|
+
output: executed.output,
|
|
329
|
+
});
|
|
330
|
+
} else {
|
|
331
|
+
results.push(runNonProcessGate(gate));
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
return results;
|
|
335
|
+
}
|
|
336
|
+
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Artifact,
|
|
3
|
+
ArtifactEdge,
|
|
4
|
+
ArtifactGraphOptions,
|
|
5
|
+
ArtifactLink,
|
|
6
|
+
ArtifactQuery,
|
|
7
|
+
CreateArtifactInput,
|
|
8
|
+
RelationshipQuery,
|
|
9
|
+
} from "../domain/artifact.ts";
|
|
10
|
+
|
|
11
|
+
export interface ArtifactStore {
|
|
12
|
+
create(input: CreateArtifactInput): Artifact;
|
|
13
|
+
get(id: string, options?: ArtifactGraphOptions): Artifact | null;
|
|
14
|
+
query(filter: ArtifactQuery): Artifact[];
|
|
15
|
+
link(link: ArtifactLink): void;
|
|
16
|
+
setStatus(id: string, status: string): Artifact | null;
|
|
17
|
+
setExtra(id: string, extra: Record<string, unknown>): Artifact | null;
|
|
18
|
+
relationships(filter?: RelationshipQuery): ArtifactEdge[];
|
|
19
|
+
}
|