@netnodeag/kraftwerk 0.4.1 → 0.7.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 +192 -6
- package/dist/cli/doctor.js +77 -2
- package/dist/cli/init.js +65 -15
- package/dist/cli/knowledge.d.ts +2 -0
- package/dist/cli/knowledge.js +213 -0
- package/dist/cli/kraftwerk.js +12 -10
- package/dist/cli/runs.js +13 -6
- package/dist/cli/ui.js +3 -4
- package/dist/config.d.ts +9 -0
- package/dist/config.js +5 -2
- package/dist/gates.d.ts +7 -0
- package/dist/gates.js +27 -0
- package/dist/harnesses/claude.js +0 -1
- package/dist/inspector/chat/acp.d.ts +2 -2
- package/dist/inspector/chat/acp.js +44 -3
- package/dist/inspector/chat/backend.d.ts +14 -0
- package/dist/inspector/chat/pi.d.ts +2 -0
- package/dist/inspector/chat/pi.js +106 -0
- package/dist/inspector/chat/sessions.d.ts +33 -0
- package/dist/inspector/chat/sessions.js +407 -0
- package/dist/inspector/chat/store.d.ts +8 -0
- package/dist/inspector/chat/store.js +69 -0
- package/dist/inspector/chat/types.d.ts +7 -0
- package/dist/inspector/context.d.ts +7 -2
- package/dist/inspector/context.js +11 -3
- package/dist/inspector/knowledge.d.ts +24 -0
- package/dist/inspector/knowledge.js +41 -0
- package/dist/inspector/routines.d.ts +56 -0
- package/dist/inspector/routines.js +266 -0
- package/dist/inspector/runner.d.ts +10 -0
- package/dist/inspector/runner.js +6 -8
- package/dist/inspector/runs.d.ts +2 -0
- package/dist/inspector/runs.js +17 -12
- package/dist/inspector/server.d.ts +2 -0
- package/dist/inspector/server.js +311 -1
- package/dist/inspector/skills.d.ts +26 -0
- package/dist/inspector/skills.js +65 -0
- package/dist/inspector/team.d.ts +57 -0
- package/dist/inspector/team.js +121 -0
- package/dist/okf.d.ts +118 -0
- package/dist/okf.js +398 -0
- package/dist/runner/docker.d.ts +3 -1
- package/dist/runner/docker.js +5 -5
- package/dist/workflow.d.ts +7 -0
- package/dist/workflow.js +19 -0
- package/dist/yaml.js +43 -10
- package/inspector/dist/assets/index-C8AKqc_i.css +1 -0
- package/inspector/dist/assets/index-G3eFm1X4.js +13 -0
- package/inspector/dist/assets/roboto-latin-400-normal-BqEyEoaF.woff2 +0 -0
- package/inspector/dist/assets/roboto-latin-400-normal-D27F5sTu.woff +0 -0
- package/inspector/dist/assets/roboto-latin-500-normal-7RbcRiD8.woff2 +0 -0
- package/inspector/dist/assets/roboto-latin-500-normal-fsTFPL3E.woff +0 -0
- package/inspector/dist/assets/roboto-latin-700-normal-BZpUvMxY.woff2 +0 -0
- package/inspector/dist/assets/roboto-latin-700-normal-YuyVweIx.woff +0 -0
- package/inspector/dist/assets/roboto-mono-latin-400-normal-DBZPkcnn.woff +0 -0
- package/inspector/dist/assets/roboto-mono-latin-400-normal-GekRknry.woff2 +0 -0
- package/inspector/dist/assets/roboto-mono-latin-500-normal-D6j6AMzd.woff +0 -0
- package/inspector/dist/assets/roboto-mono-latin-500-normal-pxIOXZmA.woff2 +0 -0
- package/inspector/dist/assets/roboto-mono-latin-700-normal-CHYrj1DZ.woff +0 -0
- package/inspector/dist/assets/roboto-mono-latin-700-normal-foxDnrFi.woff2 +0 -0
- package/inspector/dist/index.html +2 -2
- package/package.json +1 -1
- package/schema/workflow.schema.json +166 -31
- package/inspector/dist/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff +0 -0
- package/inspector/dist/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 +0 -0
- package/inspector/dist/assets/ibm-plex-mono-latin-500-normal-CB9ihrfo.woff +0 -0
- package/inspector/dist/assets/ibm-plex-mono-latin-500-normal-DSY6xOcd.woff2 +0 -0
- package/inspector/dist/assets/ibm-plex-mono-latin-600-normal-BgSNZQsw.woff2 +0 -0
- package/inspector/dist/assets/ibm-plex-mono-latin-600-normal-DWFSQ4vo.woff +0 -0
- package/inspector/dist/assets/ibm-plex-mono-latin-700-normal-7sUh57Bg.woff2 +0 -0
- package/inspector/dist/assets/ibm-plex-mono-latin-700-normal-CNHXzs6v.woff +0 -0
- package/inspector/dist/assets/ibm-plex-sans-latin-400-normal-CDDApCn2.woff2 +0 -0
- package/inspector/dist/assets/ibm-plex-sans-latin-400-normal-CYLoc0-x.woff +0 -0
- package/inspector/dist/assets/ibm-plex-sans-latin-500-normal-6ng42L7E.woff2 +0 -0
- package/inspector/dist/assets/ibm-plex-sans-latin-500-normal-BgVn5rGT.woff +0 -0
- package/inspector/dist/assets/ibm-plex-sans-latin-600-normal-Cu4Hd6ag.woff +0 -0
- package/inspector/dist/assets/ibm-plex-sans-latin-600-normal-CuJfVYMP.woff2 +0 -0
- package/inspector/dist/assets/ibm-plex-sans-latin-700-normal-Bth3BMcD.woff +0 -0
- package/inspector/dist/assets/ibm-plex-sans-latin-700-normal-Bxkt5Cjx.woff2 +0 -0
- package/inspector/dist/assets/index-BCSWkslF.css +0 -1
- package/inspector/dist/assets/index-D6WuziFQ.js +0 -9
package/dist/okf.d.ts
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OKF (Open Knowledge Format) core — kraftwerk's "Context & Knowledge"
|
|
3
|
+
* feature. Implements OKF v0.2 (github.com/GoogleCloudPlatform/knowledge-catalog):
|
|
4
|
+
* a knowledge bundle is a directory of markdown files with YAML frontmatter,
|
|
5
|
+
* one concept per file, plus the reserved index.md / log.md.
|
|
6
|
+
*
|
|
7
|
+
* Layout: <project>/knowledge/<bundle>/**\/*.md — each direct subdirectory of
|
|
8
|
+
* the knowledge root is one bundle (the unit of distribution). The root is
|
|
9
|
+
* configurable via `knowledge:` in kraftwerk.yml.
|
|
10
|
+
*
|
|
11
|
+
* Design decisions:
|
|
12
|
+
* - Writes go through writeConcept()/verifyConcept() so provenance is
|
|
13
|
+
* enforced: every put stamps `generated: { by, at }`, appends to the
|
|
14
|
+
* bundle's log.md, and regenerates index.md. Frontmatter edits use the
|
|
15
|
+
* yaml Document API, so unknown keys and comments survive round-trips
|
|
16
|
+
* (spec §4.1 requires preserving unknown keys).
|
|
17
|
+
* - index.md is fully derived from concept frontmatter — regeneration is
|
|
18
|
+
* idempotent, so concurrent writers and out-of-band edits self-heal on
|
|
19
|
+
* the next regen (or via fsck --fix).
|
|
20
|
+
* - Consumers are permissive per spec §11: unknown types, missing optional
|
|
21
|
+
* families, and broken links never reject a concept.
|
|
22
|
+
*/
|
|
23
|
+
export declare const DEFAULT_KNOWLEDGE_DIR = "knowledge";
|
|
24
|
+
export declare const OKF_VERSION = "0.2";
|
|
25
|
+
export type TrustTier = "unverified" | "machine-confirmed" | "human-reviewed";
|
|
26
|
+
export type ConceptStatus = "draft" | "stable" | "deprecated";
|
|
27
|
+
export interface VerifiedEntry {
|
|
28
|
+
by?: string;
|
|
29
|
+
at?: string;
|
|
30
|
+
}
|
|
31
|
+
export interface SourceEntry {
|
|
32
|
+
id?: string;
|
|
33
|
+
resource?: string;
|
|
34
|
+
title?: string;
|
|
35
|
+
author?: string;
|
|
36
|
+
usage_count?: number;
|
|
37
|
+
last_modified?: string;
|
|
38
|
+
}
|
|
39
|
+
export interface ConceptInfo {
|
|
40
|
+
/** Concept ID: path within the bundle, `.md` stripped (spec §2). */
|
|
41
|
+
id: string;
|
|
42
|
+
bundle: string;
|
|
43
|
+
type?: string;
|
|
44
|
+
title: string;
|
|
45
|
+
description?: string;
|
|
46
|
+
tags: string[];
|
|
47
|
+
status: ConceptStatus;
|
|
48
|
+
trustTier: TrustTier;
|
|
49
|
+
staleAfter?: string;
|
|
50
|
+
stale: boolean;
|
|
51
|
+
generated?: {
|
|
52
|
+
by?: string;
|
|
53
|
+
at?: string;
|
|
54
|
+
};
|
|
55
|
+
verified: VerifiedEntry[];
|
|
56
|
+
/** Frontmatter problem (missing block, bad YAML, missing type). */
|
|
57
|
+
error?: string;
|
|
58
|
+
}
|
|
59
|
+
export interface ConceptDetail extends ConceptInfo {
|
|
60
|
+
frontmatter: Record<string, unknown> | null;
|
|
61
|
+
body: string;
|
|
62
|
+
raw: string;
|
|
63
|
+
sources: SourceEntry[];
|
|
64
|
+
}
|
|
65
|
+
export interface BundleInfo {
|
|
66
|
+
name: string;
|
|
67
|
+
concepts: number;
|
|
68
|
+
/** Latest generated.at / verified.at across concepts, for sorting. */
|
|
69
|
+
updatedAt?: string;
|
|
70
|
+
okfVersion?: string;
|
|
71
|
+
}
|
|
72
|
+
export interface ValidationIssue {
|
|
73
|
+
bundle: string;
|
|
74
|
+
file: string;
|
|
75
|
+
level: "error" | "warning";
|
|
76
|
+
message: string;
|
|
77
|
+
}
|
|
78
|
+
export declare function knowledgeRoot(projectRoot: string, configured?: string): string;
|
|
79
|
+
export declare function safeBundleName(name: string): string;
|
|
80
|
+
/** Normalize a concept id: strip .md, forbid escapes and absolute paths. */
|
|
81
|
+
export declare function safeConceptId(id: string): string;
|
|
82
|
+
export declare function conceptFile(root: string, bundle: string, id: string): string;
|
|
83
|
+
export declare function trustTier(verified: VerifiedEntry[]): TrustTier;
|
|
84
|
+
export declare function listBundles(root: string): Promise<BundleInfo[]>;
|
|
85
|
+
export declare function listConcepts(root: string, bundle: string): Promise<ConceptInfo[]>;
|
|
86
|
+
export declare function getConcept(root: string, bundle: string, id: string): Promise<ConceptDetail | null>;
|
|
87
|
+
export interface WriteResult {
|
|
88
|
+
file: string;
|
|
89
|
+
created: boolean;
|
|
90
|
+
concept: ConceptDetail;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* The enforced write path: validates, stamps provenance, appends the
|
|
94
|
+
* bundle log, and regenerates index.md.
|
|
95
|
+
*/
|
|
96
|
+
export declare function writeConcept(root: string, bundle: string, id: string, content: string, actor: string): Promise<WriteResult>;
|
|
97
|
+
/** Append a verification event (`verified: [{ by, at }]`) to a concept. */
|
|
98
|
+
export declare function verifyConcept(root: string, bundle: string, id: string, actor: string): Promise<ConceptDetail>;
|
|
99
|
+
/**
|
|
100
|
+
* Derived, idempotent bundle-root index.md (§8): one section per top-level
|
|
101
|
+
* subdirectory plus a root section, entries pulled from frontmatter.
|
|
102
|
+
*/
|
|
103
|
+
export declare function regenerateIndex(root: string, bundle: string): Promise<void>;
|
|
104
|
+
/** Prepend an entry under today's date heading in the bundle's log.md (§9). */
|
|
105
|
+
export declare function appendLog(root: string, bundle: string, entry: string): Promise<void>;
|
|
106
|
+
export declare function initBundle(root: string, bundle: string): Promise<string>;
|
|
107
|
+
/** Conformance per §11 plus advisory warnings (missing provenance, stale). */
|
|
108
|
+
export declare function validateBundle(root: string, bundle: string): Promise<ValidationIssue[]>;
|
|
109
|
+
export interface SearchHit {
|
|
110
|
+
bundle: string;
|
|
111
|
+
id: string;
|
|
112
|
+
title: string;
|
|
113
|
+
type?: string;
|
|
114
|
+
snippet: string;
|
|
115
|
+
}
|
|
116
|
+
export declare function searchKnowledge(root: string, query: string, bundle?: string): Promise<SearchHit[]>;
|
|
117
|
+
/** Re-derive every bundle index; returns all validation issues found. */
|
|
118
|
+
export declare function fsck(root: string, fix: boolean): Promise<ValidationIssue[]>;
|
package/dist/okf.js
ADDED
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { parse as parseYaml, parseDocument } from "yaml";
|
|
4
|
+
/**
|
|
5
|
+
* OKF (Open Knowledge Format) core — kraftwerk's "Context & Knowledge"
|
|
6
|
+
* feature. Implements OKF v0.2 (github.com/GoogleCloudPlatform/knowledge-catalog):
|
|
7
|
+
* a knowledge bundle is a directory of markdown files with YAML frontmatter,
|
|
8
|
+
* one concept per file, plus the reserved index.md / log.md.
|
|
9
|
+
*
|
|
10
|
+
* Layout: <project>/knowledge/<bundle>/**\/*.md — each direct subdirectory of
|
|
11
|
+
* the knowledge root is one bundle (the unit of distribution). The root is
|
|
12
|
+
* configurable via `knowledge:` in kraftwerk.yml.
|
|
13
|
+
*
|
|
14
|
+
* Design decisions:
|
|
15
|
+
* - Writes go through writeConcept()/verifyConcept() so provenance is
|
|
16
|
+
* enforced: every put stamps `generated: { by, at }`, appends to the
|
|
17
|
+
* bundle's log.md, and regenerates index.md. Frontmatter edits use the
|
|
18
|
+
* yaml Document API, so unknown keys and comments survive round-trips
|
|
19
|
+
* (spec §4.1 requires preserving unknown keys).
|
|
20
|
+
* - index.md is fully derived from concept frontmatter — regeneration is
|
|
21
|
+
* idempotent, so concurrent writers and out-of-band edits self-heal on
|
|
22
|
+
* the next regen (or via fsck --fix).
|
|
23
|
+
* - Consumers are permissive per spec §11: unknown types, missing optional
|
|
24
|
+
* families, and broken links never reject a concept.
|
|
25
|
+
*/
|
|
26
|
+
export const DEFAULT_KNOWLEDGE_DIR = "knowledge";
|
|
27
|
+
export const OKF_VERSION = "0.2";
|
|
28
|
+
const RESERVED = new Set(["index.md", "log.md"]);
|
|
29
|
+
/* ---------- paths ---------- */
|
|
30
|
+
export function knowledgeRoot(projectRoot, configured) {
|
|
31
|
+
return path.resolve(projectRoot, configured ?? DEFAULT_KNOWLEDGE_DIR);
|
|
32
|
+
}
|
|
33
|
+
const BUNDLE_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
34
|
+
export function safeBundleName(name) {
|
|
35
|
+
if (!BUNDLE_RE.test(name))
|
|
36
|
+
throw new Error(`invalid bundle name "${name}"`);
|
|
37
|
+
return name;
|
|
38
|
+
}
|
|
39
|
+
/** Normalize a concept id: strip .md, forbid escapes and absolute paths. */
|
|
40
|
+
export function safeConceptId(id) {
|
|
41
|
+
const clean = id.replace(/\.md$/i, "").replace(/^\/+/, "");
|
|
42
|
+
if (!clean ||
|
|
43
|
+
clean.split("/").some((seg) => seg === "" || seg === "." || seg === ".." || seg.startsWith("."))) {
|
|
44
|
+
throw new Error(`invalid concept id "${id}"`);
|
|
45
|
+
}
|
|
46
|
+
const base = path.basename(clean).toLowerCase();
|
|
47
|
+
if (base === "index" || base === "log") {
|
|
48
|
+
throw new Error(`"${base}.md" is a reserved filename (spec §3.1), not a concept`);
|
|
49
|
+
}
|
|
50
|
+
return clean;
|
|
51
|
+
}
|
|
52
|
+
export function conceptFile(root, bundle, id) {
|
|
53
|
+
return path.join(root, safeBundleName(bundle), `${safeConceptId(id)}.md`);
|
|
54
|
+
}
|
|
55
|
+
function splitFrontmatter(raw) {
|
|
56
|
+
if (!raw.startsWith("---"))
|
|
57
|
+
return { fmText: null, body: raw };
|
|
58
|
+
const m = raw.match(/^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/);
|
|
59
|
+
if (!m)
|
|
60
|
+
return { fmText: null, body: raw };
|
|
61
|
+
return { fmText: m[1], body: raw.slice(m[0].length) };
|
|
62
|
+
}
|
|
63
|
+
/** Bare `verified: { by, at }` mapping counts as a one-element list (§5.2). */
|
|
64
|
+
function normalizeVerified(v) {
|
|
65
|
+
if (v == null)
|
|
66
|
+
return [];
|
|
67
|
+
const list = Array.isArray(v) ? v : [v];
|
|
68
|
+
return list
|
|
69
|
+
.filter((e) => e && typeof e === "object")
|
|
70
|
+
.map((e) => {
|
|
71
|
+
const entry = e;
|
|
72
|
+
return { by: asString(entry.by), at: asTimestamp(entry.at) };
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
export function trustTier(verified) {
|
|
76
|
+
if (verified.length === 0)
|
|
77
|
+
return "unverified";
|
|
78
|
+
return verified.some((v) => (v.by ?? "").startsWith("human:")) ? "human-reviewed" : "machine-confirmed";
|
|
79
|
+
}
|
|
80
|
+
const asString = (v) => (typeof v === "string" ? v : undefined);
|
|
81
|
+
/** YAML may parse unquoted ISO datetimes as Date objects — accept both. */
|
|
82
|
+
const asTimestamp = (v) => v instanceof Date ? v.toISOString() : typeof v === "string" ? v : undefined;
|
|
83
|
+
function parseConceptContent(bundle, id, raw) {
|
|
84
|
+
const { fmText, body } = splitFrontmatter(raw);
|
|
85
|
+
const base = {
|
|
86
|
+
id,
|
|
87
|
+
bundle,
|
|
88
|
+
title: path.basename(id),
|
|
89
|
+
tags: [],
|
|
90
|
+
status: "stable",
|
|
91
|
+
trustTier: "unverified",
|
|
92
|
+
stale: false,
|
|
93
|
+
verified: [],
|
|
94
|
+
frontmatter: null,
|
|
95
|
+
body,
|
|
96
|
+
raw,
|
|
97
|
+
sources: [],
|
|
98
|
+
};
|
|
99
|
+
if (fmText === null)
|
|
100
|
+
return { ...base, error: "no YAML frontmatter block" };
|
|
101
|
+
let fm;
|
|
102
|
+
try {
|
|
103
|
+
fm = parseYaml(fmText);
|
|
104
|
+
}
|
|
105
|
+
catch (err) {
|
|
106
|
+
return { ...base, error: `unparseable frontmatter: ${err.message}` };
|
|
107
|
+
}
|
|
108
|
+
if (fm === null || typeof fm !== "object" || Array.isArray(fm)) {
|
|
109
|
+
return { ...base, error: "frontmatter is not a mapping" };
|
|
110
|
+
}
|
|
111
|
+
const f = fm;
|
|
112
|
+
const type = asString(f.type);
|
|
113
|
+
const verified = normalizeVerified(f.verified);
|
|
114
|
+
const gen = f.generated && typeof f.generated === "object"
|
|
115
|
+
? {
|
|
116
|
+
by: asString(f.generated.by),
|
|
117
|
+
at: asTimestamp(f.generated.at),
|
|
118
|
+
}
|
|
119
|
+
: undefined;
|
|
120
|
+
const staleAfter = asTimestamp(f.stale_after);
|
|
121
|
+
const status = asString(f.status);
|
|
122
|
+
const sources = Array.isArray(f.sources)
|
|
123
|
+
? f.sources.filter((s) => s && typeof s === "object").map((s) => ({
|
|
124
|
+
id: asString(s.id),
|
|
125
|
+
resource: asString(s.resource),
|
|
126
|
+
title: asString(s.title),
|
|
127
|
+
author: asString(s.author),
|
|
128
|
+
usage_count: typeof s.usage_count === "number" ? s.usage_count : undefined,
|
|
129
|
+
last_modified: asTimestamp(s.last_modified),
|
|
130
|
+
}))
|
|
131
|
+
: [];
|
|
132
|
+
return {
|
|
133
|
+
...base,
|
|
134
|
+
frontmatter: f,
|
|
135
|
+
type,
|
|
136
|
+
title: asString(f.title) ?? path.basename(id),
|
|
137
|
+
description: asString(f.description),
|
|
138
|
+
tags: Array.isArray(f.tags) ? f.tags.filter((t) => typeof t === "string") : [],
|
|
139
|
+
status: status === "draft" || status === "deprecated" ? status : "stable",
|
|
140
|
+
trustTier: trustTier(verified),
|
|
141
|
+
staleAfter,
|
|
142
|
+
stale: !!staleAfter && Date.now() >= Date.parse(staleAfter),
|
|
143
|
+
generated: gen,
|
|
144
|
+
verified,
|
|
145
|
+
sources,
|
|
146
|
+
error: type ? undefined : 'frontmatter has no "type" field (the only required key)',
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
/* ---------- scanning ---------- */
|
|
150
|
+
async function walkMd(dir, prefix = "") {
|
|
151
|
+
const out = [];
|
|
152
|
+
const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []);
|
|
153
|
+
for (const e of entries) {
|
|
154
|
+
if (e.name.startsWith("."))
|
|
155
|
+
continue;
|
|
156
|
+
if (e.isDirectory())
|
|
157
|
+
out.push(...(await walkMd(path.join(dir, e.name), `${prefix}${e.name}/`)));
|
|
158
|
+
else if (e.name.toLowerCase().endsWith(".md") && !RESERVED.has(e.name.toLowerCase())) {
|
|
159
|
+
out.push(`${prefix}${e.name}`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return out.sort();
|
|
163
|
+
}
|
|
164
|
+
export async function listBundles(root) {
|
|
165
|
+
const entries = await fs.readdir(root, { withFileTypes: true }).catch(() => []);
|
|
166
|
+
const bundles = [];
|
|
167
|
+
for (const e of entries) {
|
|
168
|
+
if (!e.isDirectory() || e.name.startsWith("."))
|
|
169
|
+
continue;
|
|
170
|
+
const concepts = await listConcepts(root, e.name);
|
|
171
|
+
let updatedAt;
|
|
172
|
+
for (const c of concepts) {
|
|
173
|
+
for (const ts of [c.generated?.at, ...c.verified.map((v) => v.at)]) {
|
|
174
|
+
if (ts && (!updatedAt || ts > updatedAt))
|
|
175
|
+
updatedAt = ts;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
bundles.push({
|
|
179
|
+
name: e.name,
|
|
180
|
+
concepts: concepts.length,
|
|
181
|
+
updatedAt,
|
|
182
|
+
okfVersion: await bundleOkfVersion(path.join(root, e.name)),
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
return bundles.sort((a, b) => a.name.localeCompare(b.name));
|
|
186
|
+
}
|
|
187
|
+
async function bundleOkfVersion(bundleDir) {
|
|
188
|
+
const raw = await fs.readFile(path.join(bundleDir, "index.md"), "utf8").catch(() => null);
|
|
189
|
+
if (!raw)
|
|
190
|
+
return undefined;
|
|
191
|
+
const { fmText } = splitFrontmatter(raw);
|
|
192
|
+
if (!fmText)
|
|
193
|
+
return undefined;
|
|
194
|
+
try {
|
|
195
|
+
const fm = parseYaml(fmText);
|
|
196
|
+
const v = fm?.okf_version;
|
|
197
|
+
return typeof v === "string" ? v : typeof v === "number" ? String(v) : undefined;
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
return undefined;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
export async function listConcepts(root, bundle) {
|
|
204
|
+
const dir = path.join(root, safeBundleName(bundle));
|
|
205
|
+
const files = await walkMd(dir);
|
|
206
|
+
const concepts = await Promise.all(files.map(async (rel) => {
|
|
207
|
+
const id = rel.replace(/\.md$/i, "");
|
|
208
|
+
const raw = await fs.readFile(path.join(dir, rel), "utf8").catch(() => "");
|
|
209
|
+
const { frontmatter, body, raw: _r, sources, ...info } = parseConceptContent(bundle, id, raw);
|
|
210
|
+
return info;
|
|
211
|
+
}));
|
|
212
|
+
return concepts;
|
|
213
|
+
}
|
|
214
|
+
export async function getConcept(root, bundle, id) {
|
|
215
|
+
const file = conceptFile(root, bundle, id);
|
|
216
|
+
const raw = await fs.readFile(file, "utf8").catch(() => null);
|
|
217
|
+
if (raw === null)
|
|
218
|
+
return null;
|
|
219
|
+
return parseConceptContent(bundle, safeConceptId(id), raw);
|
|
220
|
+
}
|
|
221
|
+
/* ---------- writing ---------- */
|
|
222
|
+
const nowIso = () => new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
223
|
+
/**
|
|
224
|
+
* Re-emit content with `generated: { by, at }` stamped, using the yaml
|
|
225
|
+
* Document API so every other frontmatter key (and comments) round-trips.
|
|
226
|
+
*/
|
|
227
|
+
function stampGenerated(raw, actor) {
|
|
228
|
+
const { fmText, body } = splitFrontmatter(raw);
|
|
229
|
+
if (fmText === null)
|
|
230
|
+
throw new Error("concept needs a YAML frontmatter block (--- ... ---)");
|
|
231
|
+
const doc = parseDocument(fmText);
|
|
232
|
+
if (doc.errors.length > 0) {
|
|
233
|
+
throw new Error(`unparseable frontmatter: ${doc.errors[0].message}`);
|
|
234
|
+
}
|
|
235
|
+
const fm = doc.toJS();
|
|
236
|
+
if (!fm || typeof fm !== "object")
|
|
237
|
+
throw new Error("frontmatter is not a mapping");
|
|
238
|
+
if (typeof fm.type !== "string" || !fm.type.trim()) {
|
|
239
|
+
throw new Error('frontmatter needs a non-empty "type" field (spec §4.1)');
|
|
240
|
+
}
|
|
241
|
+
const node = doc.createNode({ by: actor, at: nowIso() });
|
|
242
|
+
node.flow = true;
|
|
243
|
+
doc.set("generated", node);
|
|
244
|
+
return `---\n${doc.toString().trimEnd()}\n---\n${body.startsWith("\n") ? "" : "\n"}${body}`;
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* The enforced write path: validates, stamps provenance, appends the
|
|
248
|
+
* bundle log, and regenerates index.md.
|
|
249
|
+
*/
|
|
250
|
+
export async function writeConcept(root, bundle, id, content, actor) {
|
|
251
|
+
const cleanId = safeConceptId(id);
|
|
252
|
+
const file = conceptFile(root, bundle, cleanId);
|
|
253
|
+
const stamped = stampGenerated(content, actor);
|
|
254
|
+
const created = !(await fs.stat(file).catch(() => null));
|
|
255
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
256
|
+
await fs.writeFile(file, stamped);
|
|
257
|
+
const concept = parseConceptContent(bundle, cleanId, stamped);
|
|
258
|
+
const label = concept.title === path.basename(cleanId) ? cleanId : concept.title;
|
|
259
|
+
await appendLog(root, bundle, `**${created ? "Creation" : "Update"}**: ${created ? "Created" : "Updated"} [${label}](/${cleanId}.md) (${actor}).`);
|
|
260
|
+
await regenerateIndex(root, bundle);
|
|
261
|
+
return { file, created, concept };
|
|
262
|
+
}
|
|
263
|
+
/** Append a verification event (`verified: [{ by, at }]`) to a concept. */
|
|
264
|
+
export async function verifyConcept(root, bundle, id, actor) {
|
|
265
|
+
const cleanId = safeConceptId(id);
|
|
266
|
+
const file = conceptFile(root, bundle, cleanId);
|
|
267
|
+
const raw = await fs.readFile(file, "utf8").catch(() => null);
|
|
268
|
+
if (raw === null)
|
|
269
|
+
throw new Error(`concept ${bundle}/${cleanId} not found`);
|
|
270
|
+
const { fmText, body } = splitFrontmatter(raw);
|
|
271
|
+
if (fmText === null)
|
|
272
|
+
throw new Error("concept has no frontmatter to verify");
|
|
273
|
+
const doc = parseDocument(fmText);
|
|
274
|
+
if (doc.errors.length > 0)
|
|
275
|
+
throw new Error(`unparseable frontmatter: ${doc.errors[0].message}`);
|
|
276
|
+
const existing = normalizeVerified(doc.toJS()?.verified);
|
|
277
|
+
const entries = [...existing, { by: actor, at: nowIso() }];
|
|
278
|
+
const node = doc.createNode(entries);
|
|
279
|
+
for (const item of node.items ?? [])
|
|
280
|
+
item.flow = true;
|
|
281
|
+
doc.set("verified", node);
|
|
282
|
+
const next = `---\n${doc.toString().trimEnd()}\n---\n${body.startsWith("\n") ? "" : "\n"}${body}`;
|
|
283
|
+
await fs.writeFile(file, next);
|
|
284
|
+
await appendLog(root, bundle, `**Verification**: Verified [${cleanId}](/${cleanId}.md) (${actor}).`);
|
|
285
|
+
return parseConceptContent(bundle, cleanId, next);
|
|
286
|
+
}
|
|
287
|
+
/* ---------- index + log ---------- */
|
|
288
|
+
/**
|
|
289
|
+
* Derived, idempotent bundle-root index.md (§8): one section per top-level
|
|
290
|
+
* subdirectory plus a root section, entries pulled from frontmatter.
|
|
291
|
+
*/
|
|
292
|
+
export async function regenerateIndex(root, bundle) {
|
|
293
|
+
const dir = path.join(root, safeBundleName(bundle));
|
|
294
|
+
const concepts = await listConcepts(root, bundle);
|
|
295
|
+
const groups = new Map();
|
|
296
|
+
for (const c of concepts) {
|
|
297
|
+
const top = c.id.includes("/") ? c.id.slice(0, c.id.indexOf("/")) : "";
|
|
298
|
+
const list = groups.get(top) ?? [];
|
|
299
|
+
list.push(c);
|
|
300
|
+
groups.set(top, list);
|
|
301
|
+
}
|
|
302
|
+
const sections = [];
|
|
303
|
+
const keys = [...groups.keys()].sort((a, b) => (a === "" ? -1 : b === "" ? 1 : a.localeCompare(b)));
|
|
304
|
+
for (const key of keys) {
|
|
305
|
+
const heading = key === "" ? bundle : key;
|
|
306
|
+
const lines = groups
|
|
307
|
+
.get(key)
|
|
308
|
+
.map((c) => `* [${c.title}](${c.id}.md)${c.description ? ` - ${c.description}` : ""}`);
|
|
309
|
+
sections.push(`# ${heading}\n\n${lines.join("\n")}`);
|
|
310
|
+
}
|
|
311
|
+
const content = `---\nokf_version: "${OKF_VERSION}"\n---\n\n` +
|
|
312
|
+
(sections.length > 0 ? sections.join("\n\n") + "\n" : `# ${bundle}\n\n(no concepts yet)\n`);
|
|
313
|
+
await fs.mkdir(dir, { recursive: true });
|
|
314
|
+
await fs.writeFile(path.join(dir, "index.md"), content);
|
|
315
|
+
}
|
|
316
|
+
/** Prepend an entry under today's date heading in the bundle's log.md (§9). */
|
|
317
|
+
export async function appendLog(root, bundle, entry) {
|
|
318
|
+
const file = path.join(root, safeBundleName(bundle), "log.md");
|
|
319
|
+
const today = nowIso().slice(0, 10);
|
|
320
|
+
let raw = await fs.readFile(file, "utf8").catch(() => null);
|
|
321
|
+
if (raw === null)
|
|
322
|
+
raw = `# ${bundle} Update Log\n`;
|
|
323
|
+
const lines = raw.split("\n");
|
|
324
|
+
const todayIdx = lines.findIndex((l) => l.trim() === `## ${today}`);
|
|
325
|
+
if (todayIdx >= 0) {
|
|
326
|
+
lines.splice(todayIdx + 1, 0, `* ${entry}`);
|
|
327
|
+
}
|
|
328
|
+
else {
|
|
329
|
+
// New day: insert right after the title (newest first).
|
|
330
|
+
const titleIdx = lines.findIndex((l) => l.startsWith("# "));
|
|
331
|
+
lines.splice(titleIdx + 1, 0, "", `## ${today}`, `* ${entry}`);
|
|
332
|
+
}
|
|
333
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
334
|
+
await fs.writeFile(file, lines.join("\n").replace(/\n{3,}/g, "\n\n"));
|
|
335
|
+
}
|
|
336
|
+
/* ---------- init / validate / search ---------- */
|
|
337
|
+
export async function initBundle(root, bundle) {
|
|
338
|
+
const dir = path.join(root, safeBundleName(bundle));
|
|
339
|
+
if (await fs.stat(dir).catch(() => null))
|
|
340
|
+
throw new Error(`bundle "${bundle}" already exists`);
|
|
341
|
+
await fs.mkdir(dir, { recursive: true });
|
|
342
|
+
await regenerateIndex(root, bundle);
|
|
343
|
+
await appendLog(root, bundle, `**Initialization**: Created the ${bundle} bundle.`);
|
|
344
|
+
return dir;
|
|
345
|
+
}
|
|
346
|
+
/** Conformance per §11 plus advisory warnings (missing provenance, stale). */
|
|
347
|
+
export async function validateBundle(root, bundle) {
|
|
348
|
+
const issues = [];
|
|
349
|
+
const dir = path.join(root, safeBundleName(bundle));
|
|
350
|
+
for (const rel of await walkMd(dir)) {
|
|
351
|
+
const id = rel.replace(/\.md$/i, "");
|
|
352
|
+
const raw = await fs.readFile(path.join(dir, rel), "utf8").catch(() => "");
|
|
353
|
+
const c = parseConceptContent(bundle, id, raw);
|
|
354
|
+
if (c.error)
|
|
355
|
+
issues.push({ bundle, file: rel, level: "error", message: c.error });
|
|
356
|
+
else {
|
|
357
|
+
if (!c.generated?.by) {
|
|
358
|
+
issues.push({ bundle, file: rel, level: "warning", message: "no generated.by provenance — write via `kraftwerk knowledge put` to stamp it" });
|
|
359
|
+
}
|
|
360
|
+
if (c.stale) {
|
|
361
|
+
issues.push({ bundle, file: rel, level: "warning", message: `stale since ${c.staleAfter}` });
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
return issues;
|
|
366
|
+
}
|
|
367
|
+
export async function searchKnowledge(root, query, bundle) {
|
|
368
|
+
const q = query.toLowerCase();
|
|
369
|
+
const bundles = bundle ? [bundle] : (await listBundles(root)).map((b) => b.name);
|
|
370
|
+
const hits = [];
|
|
371
|
+
for (const b of bundles) {
|
|
372
|
+
const dir = path.join(root, safeBundleName(b));
|
|
373
|
+
for (const rel of await walkMd(dir)) {
|
|
374
|
+
const raw = await fs.readFile(path.join(dir, rel), "utf8").catch(() => "");
|
|
375
|
+
const idx = raw.toLowerCase().indexOf(q);
|
|
376
|
+
if (idx < 0)
|
|
377
|
+
continue;
|
|
378
|
+
const c = parseConceptContent(b, rel.replace(/\.md$/i, ""), raw);
|
|
379
|
+
const start = Math.max(0, idx - 60);
|
|
380
|
+
const snippet = raw
|
|
381
|
+
.slice(start, idx + q.length + 60)
|
|
382
|
+
.replace(/\s+/g, " ")
|
|
383
|
+
.trim();
|
|
384
|
+
hits.push({ bundle: b, id: c.id, title: c.title, type: c.type, snippet });
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
return hits;
|
|
388
|
+
}
|
|
389
|
+
/** Re-derive every bundle index; returns all validation issues found. */
|
|
390
|
+
export async function fsck(root, fix) {
|
|
391
|
+
const issues = [];
|
|
392
|
+
for (const b of await listBundles(root)) {
|
|
393
|
+
issues.push(...(await validateBundle(root, b.name)));
|
|
394
|
+
if (fix)
|
|
395
|
+
await regenerateIndex(root, b.name);
|
|
396
|
+
}
|
|
397
|
+
return issues;
|
|
398
|
+
}
|
package/dist/runner/docker.d.ts
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
export interface SandboxOptions {
|
|
2
2
|
/** Consumer project root (where output/ lives). */
|
|
3
3
|
projectRoot: string;
|
|
4
|
+
/** Output directory; defaults to <projectRoot>/output (kraftwerk.yml `output:` may differ). */
|
|
5
|
+
outputDir?: string;
|
|
4
6
|
/** Workflow folder (folder mode) or .yml file path. */
|
|
5
7
|
workflowPath: string;
|
|
6
8
|
/** Workflow name as declared in the YAML (used for `kraftwerk run <name>`). */
|
|
7
9
|
workflowName: string;
|
|
8
10
|
request: string;
|
|
9
|
-
/** Pre-chosen run id
|
|
11
|
+
/** Pre-chosen run id; generated from workflow name + stamp when omitted. */
|
|
10
12
|
runId?: string;
|
|
11
13
|
/** Forward the host SSH agent + known_hosts into the container. */
|
|
12
14
|
ssh?: boolean;
|
package/dist/runner/docker.js
CHANGED
|
@@ -4,7 +4,7 @@ import { existsSync } from "node:fs";
|
|
|
4
4
|
import os from "node:os";
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
|
-
import {
|
|
7
|
+
import { newRunId, runDirFor } from "../workflow.js";
|
|
8
8
|
/**
|
|
9
9
|
* Docker sandbox runner: one container per workflow run.
|
|
10
10
|
*
|
|
@@ -57,8 +57,8 @@ export async function runSandboxed(opts) {
|
|
|
57
57
|
if (!imageExists()) {
|
|
58
58
|
throw new Error(`Image "${IMAGE}" not found — run \`kraftwerk runner build\` first.`);
|
|
59
59
|
}
|
|
60
|
-
const runId = opts.runId ??
|
|
61
|
-
const runDir = path.join(opts.projectRoot, "output", runId);
|
|
60
|
+
const runId = opts.runId ?? newRunId(opts.workflowName);
|
|
61
|
+
const runDir = runDirFor(opts.outputDir ?? path.join(opts.projectRoot, "output"), runId);
|
|
62
62
|
await mkdir(runDir, { recursive: true });
|
|
63
63
|
const containerName = `kw-${runId}`;
|
|
64
64
|
// Folder mode and single-file mode both mount under src/workflows/ so
|
|
@@ -73,8 +73,8 @@ export async function runSandboxed(opts) {
|
|
|
73
73
|
"--memory", opts.memory ?? "2g",
|
|
74
74
|
"--cpus", opts.cpus ?? "2",
|
|
75
75
|
"-v", `${wfAbs}:${wfTarget}:ro`,
|
|
76
|
-
"-v", `${runDir}:/work/output/${runId}`,
|
|
77
|
-
"-e", `KRAFTWERK_RUN_DIR=/work/output/${runId}`,
|
|
76
|
+
"-v", `${runDir}:/work/output/runs/${runId}`,
|
|
77
|
+
"-e", `KRAFTWERK_RUN_DIR=/work/output/runs/${runId}`,
|
|
78
78
|
];
|
|
79
79
|
const envFile = path.join(opts.projectRoot, ENV_FILE);
|
|
80
80
|
if (existsSync(envFile))
|
package/dist/workflow.d.ts
CHANGED
|
@@ -22,3 +22,10 @@ export interface WorkflowDefinition {
|
|
|
22
22
|
}
|
|
23
23
|
/** Local-time run-folder stamp: "2026-08-13-1432-07" (sortable, no colons). */
|
|
24
24
|
export declare function runStamp(date?: Date): string;
|
|
25
|
+
/** Run-folder name: "<stamp>-<workflow-slug>", e.g. "2026-08-25-1432-07-hello". */
|
|
26
|
+
export declare function newRunId(workflowName: string, date?: Date): string;
|
|
27
|
+
/**
|
|
28
|
+
* Where a run's folder lives: <output>/runs/<id>. Legacy ids ("run-<stamp>",
|
|
29
|
+
* pre-runs-subfolder) stay at the output root so old runs remain readable.
|
|
30
|
+
*/
|
|
31
|
+
export declare function runDirFor(outputDir: string, runId: string): string;
|
package/dist/workflow.js
CHANGED
|
@@ -3,9 +3,28 @@
|
|
|
3
3
|
* A workflow owns its run directory, roster, prompts, and gates; the
|
|
4
4
|
* framework only provides the phase runner and the CLI dispatch.
|
|
5
5
|
*/
|
|
6
|
+
import path from "node:path";
|
|
6
7
|
/** Local-time run-folder stamp: "2026-08-13-1432-07" (sortable, no colons). */
|
|
7
8
|
export function runStamp(date = new Date()) {
|
|
8
9
|
const pad = (n) => String(n).padStart(2, "0");
|
|
9
10
|
return (`${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` +
|
|
10
11
|
`-${pad(date.getHours())}${pad(date.getMinutes())}-${pad(date.getSeconds())}`);
|
|
11
12
|
}
|
|
13
|
+
/** Run-folder name: "<stamp>-<workflow-slug>", e.g. "2026-08-25-1432-07-hello". */
|
|
14
|
+
export function newRunId(workflowName, date = new Date()) {
|
|
15
|
+
const slug = workflowName
|
|
16
|
+
.toLowerCase()
|
|
17
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
18
|
+
.replace(/^-+|-+$/g, "")
|
|
19
|
+
.slice(0, 40) || "workflow";
|
|
20
|
+
return `${runStamp(date)}-${slug}`;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Where a run's folder lives: <output>/runs/<id>. Legacy ids ("run-<stamp>",
|
|
24
|
+
* pre-runs-subfolder) stay at the output root so old runs remain readable.
|
|
25
|
+
*/
|
|
26
|
+
export function runDirFor(outputDir, runId) {
|
|
27
|
+
return runId.startsWith("run-")
|
|
28
|
+
? path.join(outputDir, runId)
|
|
29
|
+
: path.join(outputDir, "runs", runId);
|
|
30
|
+
}
|