@netnodeag/kraftwerk 0.78.1 → 0.80.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 +83 -0
- package/dist/cli/daemon.js +10 -9
- package/dist/cli/doctor.js +1 -1
- package/dist/cli/entities.d.ts +8 -0
- package/dist/cli/entities.js +222 -0
- package/dist/cli/init.js +3 -0
- package/dist/cli/kraftwerk.js +7 -0
- package/dist/cli/ui.js +2 -1
- package/dist/client/routes.d.ts +16 -0
- package/dist/client/routes.js +16 -0
- package/dist/config.d.ts +25 -0
- package/dist/config.js +17 -6
- package/dist/core/chat/context.js +5 -2
- package/dist/core/entities.d.ts +171 -0
- package/dist/core/entities.js +441 -0
- package/dist/core/git.js +3 -1
- package/dist/core/journal.d.ts +12 -2
- package/dist/core/journal.js +50 -5
- package/dist/core/log.d.ts +18 -0
- package/dist/core/log.js +23 -0
- package/dist/core/projects.d.ts +22 -33
- package/dist/core/projects.js +40 -134
- package/dist/core/records.d.ts +28 -0
- package/dist/core/records.js +71 -0
- package/dist/core/refs.d.ts +74 -0
- package/dist/core/refs.js +143 -0
- package/dist/core/search.d.ts +10 -0
- package/dist/core/search.js +12 -0
- package/dist/core/settings.d.ts +4 -0
- package/dist/core/settings.js +24 -3
- package/dist/core/slug.d.ts +9 -0
- package/dist/core/slug.js +18 -0
- package/dist/core/trash.d.ts +1 -1
- package/dist/server/api/devices.js +2 -1
- package/dist/server/api/entities.d.ts +74 -0
- package/dist/server/api/entities.js +60 -0
- package/dist/server/api/index.d.ts +66 -4
- package/dist/server/api/index.js +5 -0
- package/dist/server/api/projects.d.ts +3 -0
- package/dist/server/api/projects.js +3 -0
- package/dist/server/api/workspace.d.ts +15 -0
- package/dist/server/api/workspace.js +12 -4
- package/dist/server/cloud.js +3 -2
- package/dist/server/relay.d.ts +3 -2
- package/dist/server/relay.js +7 -7
- package/dist/server/server.js +3 -2
- package/package.json +1 -1
- package/schema/kraftwerk.schema.json +54 -0
- package/web/dist/assets/{dist-Crnb8VVO.js → dist-1PajaYPg.js} +1 -1
- package/web/dist/assets/{dist-B-yVL5lk.js → dist-B4dqwo7_.js} +1 -1
- package/web/dist/assets/dist-Bp1iMuQ7.js +1 -0
- package/web/dist/assets/{dist-C90kUzps.js → dist-CK2u1OXb.js} +1 -1
- package/web/dist/assets/{dist-DQbifCX0.js → dist-D-f0nszo.js} +1 -1
- package/web/dist/assets/{dist-BTc878j7.js → dist-DKduisGW.js} +1 -1
- package/web/dist/assets/{dist-D0rjnN8S.js → dist-DgKgeYFJ.js} +1 -1
- package/web/dist/assets/{dist-D4SDXoGw.js → dist-DlZPeneT.js} +1 -1
- package/web/dist/assets/{dist-DHRhdODt.js → dist-K_3Y5Nbl.js} +1 -1
- package/web/dist/assets/{dist-B9EMB1Ie.js → dist-NX4_yvho.js} +1 -1
- package/web/dist/assets/{dist-CpLdAqFQ.js → dist-u_oAKIhZ.js} +1 -1
- package/web/dist/assets/{editor-DHAdW1g_.js → editor-CE9TlKkJ.js} +3 -3
- package/web/dist/assets/index-Dvt-vE1h.css +2 -0
- package/web/dist/assets/index-IAuwD22j.js +231 -0
- package/web/dist/index.html +2 -2
- package/web/dist-remote/assets/index-D6Wznb5o.js +27 -0
- package/web/dist-remote/index.html +2 -2
- package/web/dist-remote/sw.js +1 -1
- package/web/dist/assets/dist-lSJ-O7kT.js +0 -1
- package/web/dist/assets/index-BKDJocDd.css +0 -2
- package/web/dist/assets/index-D7JVAjll.js +0 -231
- package/web/dist-remote/assets/index-2nAI1HTV.js +0 -13
|
@@ -7,6 +7,7 @@ import { getAgent, listAgents } from "../agents.js";
|
|
|
7
7
|
import { listRepos } from "../repos.js";
|
|
8
8
|
import { listVibeables, vibeableStatus, VIBEABLE_CONFIG_FILE } from "../vibeables.js";
|
|
9
9
|
import { projectContext } from "../projects.js";
|
|
10
|
+
import { entitiesContext } from "../entities.js";
|
|
10
11
|
import { unattendedTimeoutLabel } from "./permissions.js";
|
|
11
12
|
import { getChannel } from "../channels.js";
|
|
12
13
|
import { JOURNAL_FILE, journalExcerpt, readJournal } from "../journal.js";
|
|
@@ -391,7 +392,7 @@ export async function scopeContext(meta, seat) {
|
|
|
391
392
|
const agent = seat.harness;
|
|
392
393
|
// The repositories block reads every clone from git, so it runs alongside
|
|
393
394
|
// the rest instead of adding its spawns to the first prompt's latency.
|
|
394
|
-
const [base, repos, vibe, skills, channel, project] = await Promise.all([
|
|
395
|
+
const [base, repos, vibe, skills, channel, project, entities] = await Promise.all([
|
|
395
396
|
baseScopeContext(scope, agent),
|
|
396
397
|
scope.kind === "agent" || scope.kind === "kraftwerk" ? reposContext() : Promise.resolve(""),
|
|
397
398
|
meta.vibeable ? vibeableContext(meta.vibeable, meta.cwd) : Promise.resolve(""),
|
|
@@ -399,8 +400,10 @@ export async function scopeContext(meta, seat) {
|
|
|
399
400
|
meta.scope.kind === "channel" ? channelContext(meta.scope.slug, seat.key) : Promise.resolve(""),
|
|
400
401
|
// A channel that works in a project: every member reads the project too, as a member, not as its assistant.
|
|
401
402
|
meta.scope.kind === "channel" && meta.project ? projectContext(meta.project, `${seat.key}/${agent}`, { member: true }) : Promise.resolve(""),
|
|
403
|
+
// Who, where and when: the workspace's contacts, places and events, wherever the chat works (a run discusses its own artifacts only).
|
|
404
|
+
scope.kind === "run" ? Promise.resolve("") : entitiesContext().catch(() => ""),
|
|
402
405
|
]);
|
|
403
|
-
return [base, channel, project, repos, vibe, RENDERING_BLOCK, skillsBlock(skills)].filter(Boolean).join("\n\n");
|
|
406
|
+
return [base, channel, project, repos, entities, vibe, RENDERING_BLOCK, skillsBlock(skills)].filter(Boolean).join("\n\n");
|
|
404
407
|
}
|
|
405
408
|
/** The channel block a member agent gets once per process: who is here, how turns work, how to hand over. */
|
|
406
409
|
async function channelContext(slug, self) {
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { type EntityKind, type ResolvedWorkspace } from "../config.js";
|
|
2
|
+
import { recordLine, type SystemOfRecord } from "./records.js";
|
|
3
|
+
import { type LinkState, type RefKind } from "./refs.js";
|
|
4
|
+
/**
|
|
5
|
+
* Contacts, places and events: the workspace's own records of who, where
|
|
6
|
+
* and when. One Markdown file per entry under the kind's root (`contacts:`,
|
|
7
|
+
* `places:`, `events:` in kraftwerk.yml, default kraftwerk-data/<kind>/):
|
|
8
|
+
* the typed fields in YAML frontmatter, free notes in the body. Readable
|
|
9
|
+
* and editable by people and agents, versioned by the workspace git,
|
|
10
|
+
* deleted into the trash.
|
|
11
|
+
*
|
|
12
|
+
* contacts/anna-meier.md title, role, org, email, phone, place, url, tags, records + notes
|
|
13
|
+
* places/netnode-office.md title, address, lat, lng, url, tags, records + notes
|
|
14
|
+
* events/2026-11-03-kickoff.md title, start, end, place, contacts, project, url, tags, records + notes
|
|
15
|
+
*
|
|
16
|
+
* The three kinds share this store; each is a small definition (its
|
|
17
|
+
* fields, how its slug derives, how it sorts, how it reads in one line).
|
|
18
|
+
* Fields that point at other things (a contact's place, an event's place,
|
|
19
|
+
* contacts and project) hold slugs and are resolved through the reference
|
|
20
|
+
* registry (refs.ts), so a missing target is reported, never an error.
|
|
21
|
+
* Systems of record say where the truth lives outside kraftwerk (CRM,
|
|
22
|
+
* newsletter tool, calendar, Google Maps); a place without one gets a
|
|
23
|
+
* Google Maps link derived from its coordinates or address.
|
|
24
|
+
*/
|
|
25
|
+
export interface EntityBase {
|
|
26
|
+
slug: string;
|
|
27
|
+
title: string;
|
|
28
|
+
tags: string[];
|
|
29
|
+
records: SystemOfRecord[];
|
|
30
|
+
/** Frontmatter keys this kind does not define; kept verbatim across saves. */
|
|
31
|
+
extra?: Record<string, unknown>;
|
|
32
|
+
}
|
|
33
|
+
export interface ContactDef extends EntityBase {
|
|
34
|
+
role?: string;
|
|
35
|
+
org?: string;
|
|
36
|
+
email?: string;
|
|
37
|
+
phone?: string;
|
|
38
|
+
/** A place's slug (place:<slug>). */
|
|
39
|
+
place?: string;
|
|
40
|
+
url?: string;
|
|
41
|
+
}
|
|
42
|
+
export interface PlaceDef extends EntityBase {
|
|
43
|
+
address?: string;
|
|
44
|
+
lat?: number;
|
|
45
|
+
lng?: number;
|
|
46
|
+
url?: string;
|
|
47
|
+
/** Derived, never written: the google-maps record's URL, else one from lat/lng or the address. */
|
|
48
|
+
mapsUrl?: string;
|
|
49
|
+
}
|
|
50
|
+
export interface EventDef extends EntityBase {
|
|
51
|
+
/** ISO date (2026-11-03) or date-time (2026-11-03T14:00, optionally with seconds and a zone). */
|
|
52
|
+
start: string;
|
|
53
|
+
end?: string;
|
|
54
|
+
/** A place's slug. */
|
|
55
|
+
place?: string;
|
|
56
|
+
/** Contact slugs. */
|
|
57
|
+
contacts: string[];
|
|
58
|
+
/** A project's slug. */
|
|
59
|
+
project?: string;
|
|
60
|
+
url?: string;
|
|
61
|
+
}
|
|
62
|
+
export type FieldType = "text" | "email" | "url" | "number" | "date" | "list" | "ref" | "refs";
|
|
63
|
+
/** One typed frontmatter field beyond the shared ones (title, tags, records); drives validation, the CLI options and the web form. */
|
|
64
|
+
export interface FieldSpec {
|
|
65
|
+
name: string;
|
|
66
|
+
label: string;
|
|
67
|
+
type: FieldType;
|
|
68
|
+
/** For ref/refs: what the slugs point at. */
|
|
69
|
+
refKind?: RefKind;
|
|
70
|
+
required?: boolean;
|
|
71
|
+
/** One line for the CLI option and the form's placeholder. */
|
|
72
|
+
hint: string;
|
|
73
|
+
}
|
|
74
|
+
export interface EntityKindDef<D extends EntityBase = EntityBase, K extends EntityKind = EntityKind> {
|
|
75
|
+
/** The literal kind, so a factory built on the definition yields `contacts.list` and `/api/contacts/:slug` as types. */
|
|
76
|
+
kind: K;
|
|
77
|
+
/** The token in references: contact in contact:anna-meier. */
|
|
78
|
+
singular: string;
|
|
79
|
+
/** Material icon name the UI shows for the kind. */
|
|
80
|
+
icon: string;
|
|
81
|
+
fields: FieldSpec[];
|
|
82
|
+
/** The default slug of a new entry. */
|
|
83
|
+
slugFor(def: D): string;
|
|
84
|
+
/** List order. */
|
|
85
|
+
sort(a: D, b: D): number;
|
|
86
|
+
/** What says more than the title (role and org, address, date); "" when there is nothing. */
|
|
87
|
+
detail(def: D): string;
|
|
88
|
+
/** One line that stands for the entry in lists, link labels and agent context: the title with its detail. */
|
|
89
|
+
describe(def: D): string;
|
|
90
|
+
/** Checks across fields (an event's end after its start); throws the one error to show. */
|
|
91
|
+
validate?(def: D): void;
|
|
92
|
+
/** Fields computed on read and never written (a place's maps URL); every derived key is present, undefined when there is no value. */
|
|
93
|
+
derive?(def: D): Partial<D>;
|
|
94
|
+
/** The days an entry covers, for a `from`/`to` filter on the list: an entry counts while it is still running. */
|
|
95
|
+
span?(def: D): {
|
|
96
|
+
start: string;
|
|
97
|
+
end: string;
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
export type EntitySummary<D extends EntityBase> = D & {
|
|
101
|
+
/** Absolute file path. */
|
|
102
|
+
path: string;
|
|
103
|
+
updatedAt?: string;
|
|
104
|
+
/** Set when the file's frontmatter is unusable; the rest shows defaults. */
|
|
105
|
+
configError?: string;
|
|
106
|
+
};
|
|
107
|
+
export type EntityDetail<D extends EntityBase> = EntitySummary<D> & {
|
|
108
|
+
/** The Markdown body. */
|
|
109
|
+
notes: string;
|
|
110
|
+
/** Per ref field (place, contacts, project): the referenced slugs and whether each exists. */
|
|
111
|
+
links: Record<string, LinkState[]>;
|
|
112
|
+
};
|
|
113
|
+
export interface EntitiesView<D extends EntityBase> {
|
|
114
|
+
enabled: boolean;
|
|
115
|
+
root?: string;
|
|
116
|
+
items: EntitySummary<D>[];
|
|
117
|
+
/** Set when the feature is off or the root cannot be read. */
|
|
118
|
+
error?: string;
|
|
119
|
+
}
|
|
120
|
+
/** Fields of a create or save: shared ones, the kind's, plus notes; every one optional. */
|
|
121
|
+
export type EntityInput = Record<string, unknown> & {
|
|
122
|
+
title?: string;
|
|
123
|
+
notes?: string;
|
|
124
|
+
slug?: string;
|
|
125
|
+
};
|
|
126
|
+
export declare const CONTACTS: EntityKindDef<ContactDef, "contacts">;
|
|
127
|
+
export declare const PLACES: EntityKindDef<PlaceDef, "places">;
|
|
128
|
+
/** The Google Maps link of a place: its google-maps record, else its coordinates, else its address; undefined with none of them. */
|
|
129
|
+
export declare function mapsUrl(p: PlaceDef): string | undefined;
|
|
130
|
+
export declare const EVENTS: EntityKindDef<EventDef, "events">;
|
|
131
|
+
/**
|
|
132
|
+
* When an event's ISO value happens, as a timestamp: a plain day is local
|
|
133
|
+
* midnight (its end, when asked, the last moment of that day), a date-time
|
|
134
|
+
* is what Date reads (local without a zone, else the zone it names) — so
|
|
135
|
+
* values with different offsets order by the instant, not by their text.
|
|
136
|
+
*/
|
|
137
|
+
export declare function eventTime(iso: string, endOfDay?: boolean): number;
|
|
138
|
+
export declare const ENTITY_DEFS: {
|
|
139
|
+
[K in EntityKind]: EntityKindDef<any>;
|
|
140
|
+
};
|
|
141
|
+
export declare const entityDef: (kind: string) => EntityKindDef | undefined;
|
|
142
|
+
export declare function openEntities(kind: EntityKind): Promise<{
|
|
143
|
+
project?: ResolvedWorkspace;
|
|
144
|
+
root?: string;
|
|
145
|
+
off?: boolean;
|
|
146
|
+
error?: string;
|
|
147
|
+
}>;
|
|
148
|
+
/** Make sure the root exists; runs when the feature is switched on. */
|
|
149
|
+
export declare function ensureEntitiesRoot(kind: EntityKind): Promise<void>;
|
|
150
|
+
export declare function safeEntitySlug(def: EntityKindDef<any>, slug: string): string;
|
|
151
|
+
/** Every entry under the kind's root in the kind's order; `from`/`to` narrow kinds with a date (events) by it. */
|
|
152
|
+
export declare function listEntities<D extends EntityBase>(def: EntityKindDef<D>, range?: {
|
|
153
|
+
from?: string;
|
|
154
|
+
to?: string;
|
|
155
|
+
}): Promise<EntitiesView<D>>;
|
|
156
|
+
/** One entry with its notes and link states; null when there is no such file. */
|
|
157
|
+
export declare function getEntity<D extends EntityBase>(def: EntityKindDef<D>, slug: string): Promise<EntityDetail<D> | null>;
|
|
158
|
+
export declare function createEntity<D extends EntityBase>(def: EntityKindDef<D>, input: EntityInput): Promise<EntityDetail<D>>;
|
|
159
|
+
/** Merge the given fields into the frontmatter (and the notes when given); lists replace wholesale, "" clears a field. */
|
|
160
|
+
export declare function saveEntity<D extends EntityBase>(def: EntityKindDef<D>, slug: string, input: EntityInput): Promise<EntityDetail<D>>;
|
|
161
|
+
/** Move the file to the trash. */
|
|
162
|
+
export declare function deleteEntity(def: EntityKindDef<any>, slug: string): Promise<void>;
|
|
163
|
+
/** One line for an entry in a context block: its token, how it reads, its records. */
|
|
164
|
+
export declare function entityLine<D extends EntityBase>(def: EntityKindDef<D>, d: D): string;
|
|
165
|
+
/**
|
|
166
|
+
* The "## Contacts, places and events" block every chat starts with when at
|
|
167
|
+
* least one of the kinds is on: what exists (counts, the next events), how
|
|
168
|
+
* to reference an entry, and the CLI that reads and writes them.
|
|
169
|
+
*/
|
|
170
|
+
export declare function entitiesContext(): Promise<string>;
|
|
171
|
+
export { recordLine };
|
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { parse, stringify } from "yaml";
|
|
4
|
+
import { ENTITY_KINDS, entitiesRootFor, resolveWorkspace } from "../config.js";
|
|
5
|
+
import { workspaceRoot } from "./context.js";
|
|
6
|
+
import { cleanRecords, recordLine } from "./records.js";
|
|
7
|
+
import { resolveRefs } from "./refs.js";
|
|
8
|
+
import { SLUG_RE, slugify } from "./slug.js";
|
|
9
|
+
import { moveToTrash } from "./trash.js";
|
|
10
|
+
/* ---------- the kinds ---------- */
|
|
11
|
+
const DATE_RE = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2})?(Z|[+-]\d{2}:\d{2})?)?$/;
|
|
12
|
+
export const CONTACTS = {
|
|
13
|
+
kind: "contacts",
|
|
14
|
+
singular: "contact",
|
|
15
|
+
icon: "person",
|
|
16
|
+
fields: [
|
|
17
|
+
{ name: "role", label: "role", type: "text", hint: "What they do (CTO, project lead)" },
|
|
18
|
+
{ name: "org", label: "organisation", type: "text", hint: "Company or organisation" },
|
|
19
|
+
{ name: "email", label: "email", type: "email", hint: "Email address" },
|
|
20
|
+
{ name: "phone", label: "phone", type: "text", hint: "Phone number" },
|
|
21
|
+
{ name: "place", label: "place", type: "ref", refKind: "places", hint: "Slug of the place they are usually at (place:<slug>)" },
|
|
22
|
+
{ name: "url", label: "website", type: "url", hint: "Profile or website, http(s)" },
|
|
23
|
+
],
|
|
24
|
+
slugFor: (c) => slugify(c.title),
|
|
25
|
+
sort: (a, b) => a.title.localeCompare(b.title),
|
|
26
|
+
detail: (c) => [c.role, c.org].filter(Boolean).join(", "),
|
|
27
|
+
describe: (c) => `${c.title}${CONTACTS.detail(c) ? ` (${CONTACTS.detail(c)})` : ""}`,
|
|
28
|
+
};
|
|
29
|
+
export const PLACES = {
|
|
30
|
+
kind: "places",
|
|
31
|
+
singular: "place",
|
|
32
|
+
icon: "location_on",
|
|
33
|
+
fields: [
|
|
34
|
+
{ name: "address", label: "address", type: "text", hint: "Street, number, postcode, town" },
|
|
35
|
+
{ name: "lat", label: "latitude", type: "number", hint: "Decimal degrees (47.05)" },
|
|
36
|
+
{ name: "lng", label: "longitude", type: "number", hint: "Decimal degrees (8.30)" },
|
|
37
|
+
{ name: "url", label: "website", type: "url", hint: "Website, http(s)" },
|
|
38
|
+
],
|
|
39
|
+
slugFor: (p) => slugify(p.title),
|
|
40
|
+
sort: (a, b) => a.title.localeCompare(b.title),
|
|
41
|
+
detail: (p) => p.address ?? "",
|
|
42
|
+
describe: (p) => `${p.title}${p.address ? ` — ${p.address}` : ""}`,
|
|
43
|
+
validate: (p) => {
|
|
44
|
+
if ((p.lat === undefined) !== (p.lng === undefined))
|
|
45
|
+
throw new Error("lat and lng go together");
|
|
46
|
+
if (p.lat !== undefined && (p.lat < -90 || p.lat > 90))
|
|
47
|
+
throw new Error("lat must be between -90 and 90");
|
|
48
|
+
if (p.lng !== undefined && (p.lng < -180 || p.lng > 180))
|
|
49
|
+
throw new Error("lng must be between -180 and 180");
|
|
50
|
+
},
|
|
51
|
+
derive: (p) => ({ mapsUrl: mapsUrl(p) }),
|
|
52
|
+
};
|
|
53
|
+
/** The Google Maps link of a place: its google-maps record, else its coordinates, else its address; undefined with none of them. */
|
|
54
|
+
export function mapsUrl(p) {
|
|
55
|
+
const record = p.records.find((r) => r.kind === "google-maps" && r.url);
|
|
56
|
+
if (record?.url)
|
|
57
|
+
return record.url;
|
|
58
|
+
if (p.lat !== undefined && p.lng !== undefined)
|
|
59
|
+
return `https://www.google.com/maps?q=${p.lat},${p.lng}`;
|
|
60
|
+
if (p.address)
|
|
61
|
+
return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(p.address)}`;
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
export const EVENTS = {
|
|
65
|
+
kind: "events",
|
|
66
|
+
singular: "event",
|
|
67
|
+
icon: "event",
|
|
68
|
+
fields: [
|
|
69
|
+
{ name: "start", label: "start", type: "date", required: true, hint: "ISO date or date-time: 2026-11-03 or 2026-11-03T14:00" },
|
|
70
|
+
{ name: "end", label: "end", type: "date", hint: "ISO date or date-time, not before start" },
|
|
71
|
+
{ name: "place", label: "place", type: "ref", refKind: "places", hint: "Slug of the place (place:<slug>)" },
|
|
72
|
+
{ name: "contacts", label: "contacts", type: "refs", refKind: "contacts", hint: "Contact slugs, comma-separated" },
|
|
73
|
+
{ name: "project", label: "project", type: "ref", refKind: "projects", hint: "Slug of the project it belongs to" },
|
|
74
|
+
{ name: "url", label: "link", type: "url", hint: "Meeting link or page, http(s)" },
|
|
75
|
+
],
|
|
76
|
+
slugFor: (e) => `${e.start.slice(0, 10)}-${slugify(e.title, 37)}`.replace(/-+$/, ""),
|
|
77
|
+
sort: (a, b) => eventTime(a.start) - eventTime(b.start) || a.title.localeCompare(b.title),
|
|
78
|
+
detail: (e) => `${e.start.replace("T", " ")}${e.end ? `–${e.end.replace("T", " ")}` : ""}`,
|
|
79
|
+
describe: (e) => `${EVENTS.detail(e)} ${e.title}`,
|
|
80
|
+
validate: (e) => {
|
|
81
|
+
// A plain day as the end means "until the end of that day": start 14:00, end the same day is fine.
|
|
82
|
+
if (e.end && eventTime(e.end, true) < eventTime(e.start))
|
|
83
|
+
throw new Error("end must not be before start");
|
|
84
|
+
},
|
|
85
|
+
span: (e) => ({ start: e.start, end: e.end ?? e.start }),
|
|
86
|
+
};
|
|
87
|
+
/**
|
|
88
|
+
* When an event's ISO value happens, as a timestamp: a plain day is local
|
|
89
|
+
* midnight (its end, when asked, the last moment of that day), a date-time
|
|
90
|
+
* is what Date reads (local without a zone, else the zone it names) — so
|
|
91
|
+
* values with different offsets order by the instant, not by their text.
|
|
92
|
+
*/
|
|
93
|
+
export function eventTime(iso, endOfDay = false) {
|
|
94
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(iso)) {
|
|
95
|
+
const [y, m, d] = iso.split("-").map(Number);
|
|
96
|
+
return new Date(y, m - 1, d + (endOfDay ? 1 : 0)).getTime() - (endOfDay ? 1 : 0);
|
|
97
|
+
}
|
|
98
|
+
return Date.parse(iso);
|
|
99
|
+
}
|
|
100
|
+
export const ENTITY_DEFS = { contacts: CONTACTS, places: PLACES, events: EVENTS };
|
|
101
|
+
export const entityDef = (kind) => ENTITY_KINDS.includes(kind) ? ENTITY_DEFS[kind] : undefined;
|
|
102
|
+
/* ---------- root ---------- */
|
|
103
|
+
export async function openEntities(kind) {
|
|
104
|
+
let project;
|
|
105
|
+
try {
|
|
106
|
+
project = await resolveWorkspace(workspaceRoot());
|
|
107
|
+
}
|
|
108
|
+
catch (err) {
|
|
109
|
+
return { error: err.message };
|
|
110
|
+
}
|
|
111
|
+
const root = entitiesRootFor(project, kind);
|
|
112
|
+
if (!root)
|
|
113
|
+
return { off: true, error: `${kind} are off (enable them in settings or add \`${kind}:\` to kraftwerk.yml)` };
|
|
114
|
+
return { project, root };
|
|
115
|
+
}
|
|
116
|
+
/** Make sure the root exists; runs when the feature is switched on. */
|
|
117
|
+
export async function ensureEntitiesRoot(kind) {
|
|
118
|
+
const opened = await openEntities(kind);
|
|
119
|
+
if (opened.root)
|
|
120
|
+
await fs.mkdir(opened.root, { recursive: true });
|
|
121
|
+
}
|
|
122
|
+
async function requireRoot(kind) {
|
|
123
|
+
const opened = await openEntities(kind);
|
|
124
|
+
if (!opened.root)
|
|
125
|
+
throw new Error(opened.error ?? `${kind} are off`);
|
|
126
|
+
return opened.root;
|
|
127
|
+
}
|
|
128
|
+
export function safeEntitySlug(def, slug) {
|
|
129
|
+
if (!SLUG_RE.test(slug))
|
|
130
|
+
throw new Error(`invalid ${def.singular} name "${slug}"`);
|
|
131
|
+
return slug;
|
|
132
|
+
}
|
|
133
|
+
/* ---------- fields ---------- */
|
|
134
|
+
const strList = (v, field) => {
|
|
135
|
+
if (v === undefined || v === null || v === "")
|
|
136
|
+
return [];
|
|
137
|
+
const list = typeof v === "string" ? v.split(",") : v;
|
|
138
|
+
if (!Array.isArray(list) || !list.every((x) => typeof x === "string"))
|
|
139
|
+
throw new Error(`${field} must be a list of names`);
|
|
140
|
+
return [...new Set(list.map((x) => x.trim()).filter(Boolean))];
|
|
141
|
+
};
|
|
142
|
+
/** One field's raw value (frontmatter or input) -> its clean value, or undefined when empty; every problem is one error. */
|
|
143
|
+
function cleanField(spec, v) {
|
|
144
|
+
if (v === undefined || v === null || v === "")
|
|
145
|
+
return spec.type === "list" || spec.type === "refs" ? [] : undefined;
|
|
146
|
+
switch (spec.type) {
|
|
147
|
+
case "text":
|
|
148
|
+
case "email":
|
|
149
|
+
case "url":
|
|
150
|
+
case "date": {
|
|
151
|
+
const s = typeof v === "number" || v instanceof Date ? (v instanceof Date ? v.toISOString() : String(v)) : v;
|
|
152
|
+
if (typeof s !== "string")
|
|
153
|
+
throw new Error(`${spec.name} must be a string`);
|
|
154
|
+
const t = s.trim();
|
|
155
|
+
if (!t)
|
|
156
|
+
return undefined;
|
|
157
|
+
if (spec.type === "email" && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(t))
|
|
158
|
+
throw new Error(`${spec.name} must be an email address`);
|
|
159
|
+
if (spec.type === "url" && !/^https?:\/\//.test(t))
|
|
160
|
+
throw new Error(`${spec.name} must start with http:// or https://`);
|
|
161
|
+
if (spec.type === "date") {
|
|
162
|
+
// YAML reads a bare date as a Date; keep what the person wrote when it was a plain day.
|
|
163
|
+
const iso = v instanceof Date && /T00:00:00\.000Z$/.test(t) ? t.slice(0, 10) : t.replace(/\.\d{3}Z$/, "Z");
|
|
164
|
+
if (!DATE_RE.test(iso) || Number.isNaN(Date.parse(iso)))
|
|
165
|
+
throw new Error(`${spec.name} must be an ISO date (2026-11-03) or date-time (2026-11-03T14:00)`);
|
|
166
|
+
return iso;
|
|
167
|
+
}
|
|
168
|
+
return t;
|
|
169
|
+
}
|
|
170
|
+
case "number": {
|
|
171
|
+
const n = typeof v === "string" ? Number(v.trim()) : v;
|
|
172
|
+
if (typeof n !== "number" || !Number.isFinite(n))
|
|
173
|
+
throw new Error(`${spec.name} must be a number`);
|
|
174
|
+
return n;
|
|
175
|
+
}
|
|
176
|
+
case "list":
|
|
177
|
+
return strList(v, spec.name);
|
|
178
|
+
case "ref": {
|
|
179
|
+
if (typeof v !== "string")
|
|
180
|
+
throw new Error(`${spec.name} must be a ${spec.refKind} slug`);
|
|
181
|
+
const t = v.trim().replace(new RegExp(`^${spec.refKind.replace(/s$/, "")}:`), "");
|
|
182
|
+
if (!t)
|
|
183
|
+
return undefined;
|
|
184
|
+
if (!SLUG_RE.test(t))
|
|
185
|
+
throw new Error(`${spec.name} must be a ${spec.refKind} slug (lowercase, digits, dashes)`);
|
|
186
|
+
return t;
|
|
187
|
+
}
|
|
188
|
+
case "refs": {
|
|
189
|
+
const prefix = new RegExp(`^${spec.refKind.replace(/s$/, "")}:`);
|
|
190
|
+
return strList(v, spec.name).map((t) => {
|
|
191
|
+
const s = t.replace(prefix, "");
|
|
192
|
+
if (!SLUG_RE.test(s))
|
|
193
|
+
throw new Error(`${spec.name} must be ${spec.refKind} slugs (lowercase, digits, dashes)`);
|
|
194
|
+
return s;
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
const SHARED = new Set(["title", "tags", "records"]);
|
|
200
|
+
/** Keys a read adds around the fields; a client that sends a GET body back must not write them into the file. */
|
|
201
|
+
const READ_ONLY = new Set(["slug", "notes", "extra", "path", "updatedAt", "configError", "links"]);
|
|
202
|
+
/** Frontmatter (or a save's fields) -> a clean definition, with unknown keys kept under `extra`. */
|
|
203
|
+
function buildDef(def, slug, data) {
|
|
204
|
+
const title = typeof data.title === "string" && data.title.trim() ? data.title.trim() : slug;
|
|
205
|
+
const out = { slug, title, tags: strList(data.tags, "tags"), records: cleanRecords(data.records) };
|
|
206
|
+
const known = new Set([...SHARED, ...READ_ONLY, ...def.fields.map((f) => f.name)]);
|
|
207
|
+
for (const f of def.fields) {
|
|
208
|
+
const v = cleanField(f, data[f.name]);
|
|
209
|
+
if (v === undefined && f.required)
|
|
210
|
+
throw new Error(`${f.name} is required`);
|
|
211
|
+
if (v !== undefined)
|
|
212
|
+
out[f.name] = v;
|
|
213
|
+
}
|
|
214
|
+
for (const k of Object.keys(def.derive?.(out) ?? {}))
|
|
215
|
+
known.add(k);
|
|
216
|
+
const extra = Object.fromEntries(Object.entries(data).filter(([k, v]) => !known.has(k) && v !== undefined));
|
|
217
|
+
if (Object.keys(extra).length)
|
|
218
|
+
out.extra = extra;
|
|
219
|
+
const built = out;
|
|
220
|
+
def.validate?.(built);
|
|
221
|
+
return built;
|
|
222
|
+
}
|
|
223
|
+
const emptyDef = (def, slug) => {
|
|
224
|
+
const out = { slug, title: slug, tags: [], records: [] };
|
|
225
|
+
for (const f of def.fields)
|
|
226
|
+
if (f.type === "refs" || f.type === "list")
|
|
227
|
+
out[f.name] = [];
|
|
228
|
+
// A required field cannot be empty; a placeholder keeps the broken file visible without lying about it.
|
|
229
|
+
for (const f of def.fields)
|
|
230
|
+
if (f.required && f.type === "date")
|
|
231
|
+
out[f.name] = "0000-00-00";
|
|
232
|
+
return out;
|
|
233
|
+
};
|
|
234
|
+
/** With the derived fields added (never written). */
|
|
235
|
+
const withDerived = (def, d) => ({ ...d, ...(def.derive?.(d) ?? {}) });
|
|
236
|
+
/* ---------- files ---------- */
|
|
237
|
+
/** Split a Markdown file into its frontmatter text and body; null frontmatter when the file has none. */
|
|
238
|
+
function splitFrontmatter(raw) {
|
|
239
|
+
if (!raw.startsWith("---"))
|
|
240
|
+
return { fm: null, body: raw };
|
|
241
|
+
// An empty block (`---` straight after `---`) is frontmatter with nothing in it, not two lines of notes.
|
|
242
|
+
const m = /^---[ \t]*\r?\n---[ \t]*(\r?\n|$)/.exec(raw) ?? /^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(\r?\n|$)/.exec(raw);
|
|
243
|
+
if (!m)
|
|
244
|
+
return { fm: null, body: raw };
|
|
245
|
+
// The blank line toFile puts between the frontmatter and the body is a separator, not part of the notes.
|
|
246
|
+
return { fm: m[1] ?? "", body: raw.slice(m[0].length).replace(/^\r?\n/, "") };
|
|
247
|
+
}
|
|
248
|
+
/** The file as written: frontmatter with the known fields in their order, extras after, then the notes. */
|
|
249
|
+
function toFile(def, d, notes) {
|
|
250
|
+
const derived = new Set(Object.keys(def.derive?.(d) ?? {}));
|
|
251
|
+
const doc = { title: d.title };
|
|
252
|
+
const rec = d;
|
|
253
|
+
for (const f of def.fields) {
|
|
254
|
+
if (derived.has(f.name))
|
|
255
|
+
continue;
|
|
256
|
+
const v = rec[f.name];
|
|
257
|
+
if (v === undefined || (Array.isArray(v) && v.length === 0))
|
|
258
|
+
continue;
|
|
259
|
+
doc[f.name] = v;
|
|
260
|
+
}
|
|
261
|
+
if (d.tags.length)
|
|
262
|
+
doc.tags = d.tags;
|
|
263
|
+
if (d.records.length)
|
|
264
|
+
doc.records = d.records;
|
|
265
|
+
for (const [k, v] of Object.entries(d.extra ?? {}))
|
|
266
|
+
if (!(k in doc))
|
|
267
|
+
doc[k] = v;
|
|
268
|
+
const body = notes.trim() ? `${notes.replace(/\s+$/, "")}\n` : "";
|
|
269
|
+
return `---\n${stringify(doc)}---\n${body ? `\n${body}` : ""}`;
|
|
270
|
+
}
|
|
271
|
+
async function readFile(def, root, slug) {
|
|
272
|
+
const file = path.join(root, `${slug}.md`);
|
|
273
|
+
let raw;
|
|
274
|
+
let st;
|
|
275
|
+
try {
|
|
276
|
+
[raw, st] = await Promise.all([fs.readFile(file, "utf8"), fs.stat(file)]);
|
|
277
|
+
}
|
|
278
|
+
catch {
|
|
279
|
+
return null;
|
|
280
|
+
}
|
|
281
|
+
const updatedAt = new Date(st.mtimeMs).toISOString();
|
|
282
|
+
const { fm, body } = splitFrontmatter(raw);
|
|
283
|
+
try {
|
|
284
|
+
const data = fm === null ? {} : (parse(fm) ?? {});
|
|
285
|
+
if (typeof data !== "object" || Array.isArray(data))
|
|
286
|
+
throw new Error("frontmatter must be a mapping");
|
|
287
|
+
return { summary: { ...withDerived(def, buildDef(def, slug, data)), path: file, updatedAt }, notes: body };
|
|
288
|
+
}
|
|
289
|
+
catch (err) {
|
|
290
|
+
return { summary: { ...emptyDef(def, slug), path: file, updatedAt, configError: err.message }, notes: body };
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
/* ---------- reads ---------- */
|
|
294
|
+
/** Every entry under the kind's root in the kind's order; `from`/`to` narrow kinds with a date (events) by it. */
|
|
295
|
+
export async function listEntities(def, range = {}) {
|
|
296
|
+
const opened = await openEntities(def.kind);
|
|
297
|
+
if (!opened.root)
|
|
298
|
+
return { enabled: false, items: [], error: opened.error };
|
|
299
|
+
const root = opened.root;
|
|
300
|
+
const entries = await fs.readdir(root, { withFileTypes: true }).catch(() => []);
|
|
301
|
+
const names = entries.filter((e) => e.isFile() && e.name.endsWith(".md")).map((e) => e.name.slice(0, -3));
|
|
302
|
+
let items = (await Promise.all(names.map(async (name) => {
|
|
303
|
+
if (SLUG_RE.test(name))
|
|
304
|
+
return (await readFile(def, root, name))?.summary ?? null;
|
|
305
|
+
// A file whose name is not a slug cannot be referenced or opened; it is listed with the reason rather than hidden.
|
|
306
|
+
const file = path.join(root, `${name}.md`);
|
|
307
|
+
const st = await fs.stat(file).catch(() => null);
|
|
308
|
+
if (!st)
|
|
309
|
+
return null;
|
|
310
|
+
const configError = `file name must be a slug (lowercase letters, digits and dashes, up to 49 characters) — rename ${name}.md`;
|
|
311
|
+
return { ...emptyDef(def, name), path: file, updatedAt: new Date(st.mtimeMs).toISOString(), configError };
|
|
312
|
+
}))).filter((x) => x != null);
|
|
313
|
+
if (def.span && (range.from || range.to)) {
|
|
314
|
+
const day = (iso) => iso.slice(0, 10);
|
|
315
|
+
items = items.filter((i) => {
|
|
316
|
+
if (i.configError)
|
|
317
|
+
return false;
|
|
318
|
+
const span = def.span(i);
|
|
319
|
+
// From: anything still running on that day; to: anything that has started by then.
|
|
320
|
+
return (!range.from || day(span.end) >= day(range.from)) && (!range.to || day(span.start) <= day(range.to));
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
items.sort(def.sort);
|
|
324
|
+
return { enabled: true, root, items };
|
|
325
|
+
}
|
|
326
|
+
/** The ref fields' states: which referenced slugs exist right now. */
|
|
327
|
+
async function linksOf(def, d) {
|
|
328
|
+
const rec = d;
|
|
329
|
+
const refFields = def.fields.filter((f) => f.type === "ref" || f.type === "refs");
|
|
330
|
+
const byKind = {};
|
|
331
|
+
for (const f of refFields) {
|
|
332
|
+
const slugs = f.type === "refs" ? (rec[f.name] ?? []) : rec[f.name] ? [rec[f.name]] : [];
|
|
333
|
+
byKind[f.refKind] = [...new Set([...(byKind[f.refKind] ?? []), ...slugs])];
|
|
334
|
+
}
|
|
335
|
+
const resolved = await resolveRefs(byKind);
|
|
336
|
+
const links = {};
|
|
337
|
+
for (const f of refFields) {
|
|
338
|
+
const slugs = f.type === "refs" ? (rec[f.name] ?? []) : rec[f.name] ? [rec[f.name]] : [];
|
|
339
|
+
links[f.name] = slugs.map((s) => resolved[f.refKind].find((l) => l.slug === s) ?? { slug: s, found: false });
|
|
340
|
+
}
|
|
341
|
+
return links;
|
|
342
|
+
}
|
|
343
|
+
/** One entry with its notes and link states; null when there is no such file. */
|
|
344
|
+
export async function getEntity(def, slug) {
|
|
345
|
+
const root = await requireRoot(def.kind);
|
|
346
|
+
const read = await readFile(def, root, safeEntitySlug(def, slug));
|
|
347
|
+
if (!read)
|
|
348
|
+
return null;
|
|
349
|
+
return { ...read.summary, notes: read.notes, links: await linksOf(def, read.summary) };
|
|
350
|
+
}
|
|
351
|
+
/* ---------- writes ---------- */
|
|
352
|
+
export async function createEntity(def, input) {
|
|
353
|
+
const root = await requireRoot(def.kind);
|
|
354
|
+
const { notes, slug: wanted, ...fields } = input;
|
|
355
|
+
if (typeof fields.title !== "string" || !fields.title.trim())
|
|
356
|
+
throw new Error("title is required");
|
|
357
|
+
const d = buildDef(def, "new", fields);
|
|
358
|
+
const slug = safeEntitySlug(def, (typeof wanted === "string" ? wanted.trim() : "") || def.slugFor(d) || def.singular);
|
|
359
|
+
d.slug = slug;
|
|
360
|
+
const file = path.join(root, `${slug}.md`);
|
|
361
|
+
await fs.mkdir(root, { recursive: true });
|
|
362
|
+
try {
|
|
363
|
+
// Exclusive create: two writers racing for the same slug (the UI and an agent's CLI) cannot overwrite each other.
|
|
364
|
+
await fs.writeFile(file, toFile(def, d, typeof notes === "string" ? notes : ""), { flag: "wx" });
|
|
365
|
+
}
|
|
366
|
+
catch (err) {
|
|
367
|
+
if (err.code === "EEXIST")
|
|
368
|
+
throw new Error(`${def.singular} "${slug}" already exists`);
|
|
369
|
+
throw err;
|
|
370
|
+
}
|
|
371
|
+
return (await getEntity(def, slug));
|
|
372
|
+
}
|
|
373
|
+
/** Merge the given fields into the frontmatter (and the notes when given); lists replace wholesale, "" clears a field. */
|
|
374
|
+
export async function saveEntity(def, slug, input) {
|
|
375
|
+
const current = await getEntity(def, slug);
|
|
376
|
+
if (!current)
|
|
377
|
+
throw new Error(`no ${def.singular} "${slug}"`);
|
|
378
|
+
// A file that could not be read keeps what the person wrote: rewriting it from defaults would throw their frontmatter away.
|
|
379
|
+
if (current.configError)
|
|
380
|
+
throw new Error(`${def.singular} "${slug}" cannot be saved until its file is fixed by hand (${current.configError}): ${current.path}`);
|
|
381
|
+
const { notes, slug: _ignored, ...given } = input;
|
|
382
|
+
const { path: _p, updatedAt: _u, configError: _c, notes: _n, links: _l, extra, ...rest } = current;
|
|
383
|
+
const derived = new Set(Object.keys(def.derive?.(current) ?? {}));
|
|
384
|
+
const base = { ...extra, ...rest };
|
|
385
|
+
for (const k of derived)
|
|
386
|
+
delete base[k];
|
|
387
|
+
for (const [k, v] of Object.entries(given))
|
|
388
|
+
if (v !== undefined)
|
|
389
|
+
base[k] = v;
|
|
390
|
+
if (given.title !== undefined && !(typeof given.title === "string" && given.title.trim()))
|
|
391
|
+
throw new Error("title is required");
|
|
392
|
+
const next = buildDef(def, current.slug, base);
|
|
393
|
+
await fs.writeFile(current.path, toFile(def, next, typeof notes === "string" ? notes : current.notes));
|
|
394
|
+
return (await getEntity(def, slug));
|
|
395
|
+
}
|
|
396
|
+
/** Move the file to the trash. */
|
|
397
|
+
export async function deleteEntity(def, slug) {
|
|
398
|
+
const root = await requireRoot(def.kind);
|
|
399
|
+
const file = path.join(root, `${safeEntitySlug(def, slug)}.md`);
|
|
400
|
+
if (!(await fs.stat(file).catch(() => null)))
|
|
401
|
+
throw new Error(`no ${def.singular} "${slug}"`);
|
|
402
|
+
await moveToTrash(def.kind, slug, file);
|
|
403
|
+
}
|
|
404
|
+
/* ---------- agent context ---------- */
|
|
405
|
+
/** One line for an entry in a context block: its token, how it reads, its records. */
|
|
406
|
+
export function entityLine(def, d) {
|
|
407
|
+
const url = def.kind === "places" ? d.mapsUrl : d.url;
|
|
408
|
+
return `- ${def.singular}:${d.slug} — ${def.describe(d)}${url ? ` (${url})` : ""}`;
|
|
409
|
+
}
|
|
410
|
+
/**
|
|
411
|
+
* The "## Contacts, places and events" block every chat starts with when at
|
|
412
|
+
* least one of the kinds is on: what exists (counts, the next events), how
|
|
413
|
+
* to reference an entry, and the CLI that reads and writes them.
|
|
414
|
+
*/
|
|
415
|
+
export async function entitiesContext() {
|
|
416
|
+
const views = await Promise.all([listEntities(CONTACTS), listEntities(PLACES), listEntities(EVENTS)]);
|
|
417
|
+
const [contacts, places, events] = views;
|
|
418
|
+
if (!views.some((v) => v.enabled))
|
|
419
|
+
return "";
|
|
420
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
421
|
+
const upcoming = events.items.filter((e) => !e.configError && (e.end ?? e.start).slice(0, 10) >= today).slice(0, 5);
|
|
422
|
+
const kinds = [
|
|
423
|
+
contacts.enabled ? `- contacts (${contacts.items.length}): people and organisations — role, org, email, phone, place, records (CRM, newsletter tool)` : "",
|
|
424
|
+
places.enabled ? `- places (${places.items.length}): addresses and points of interest — address, lat/lng, records (Google Maps); \`places show <slug>\` prints the map link` : "",
|
|
425
|
+
events.enabled ? `- events (${events.items.length}): dated one-off entries — start, end, place, contacts, project, records (calendar entry)` : "",
|
|
426
|
+
].filter(Boolean);
|
|
427
|
+
return (`## Contacts, places and events\n` +
|
|
428
|
+
`The workspace keeps its own records of who, where and when as Markdown files (YAML frontmatter + notes). ` +
|
|
429
|
+
`Reference one anywhere — in notes, a project's files, your replies — as \`contact:<slug>\`, \`place:<slug>\`, \`event:<slug>\` ` +
|
|
430
|
+
`(likewise project:, agent:, workflow:, repo:, vibeable:, knowledge:); kraftwerk resolves the token and marks a missing target.\n` +
|
|
431
|
+
kinds.join("\n") +
|
|
432
|
+
`\n\nRead and write them through the CLI, never by hand:\n` +
|
|
433
|
+
`- \`npx kraftwerk contacts|places|events list --json\` (\`events list --from <date> --to <date>\`), \`… show <slug> --json\`\n` +
|
|
434
|
+
`- \`npx kraftwerk contacts create "<name>" --org … --email … --place <place-slug>\`, \`places create "<name>" --address "…"\`, ` +
|
|
435
|
+
`\`events create "<title>" --start 2026-11-03T14:00 --place <slug> --contacts a,b --project <slug>\`\n` +
|
|
436
|
+
`- \`… set <slug> --<field> <value>\` changes fields (\`--notes-file <md>\` replaces the notes), \`… remove <slug>\` moves the file to the trash, ` +
|
|
437
|
+
`\`npx kraftwerk ref <kind:slug>\` checks a reference.\n` +
|
|
438
|
+
`Fields that point at other entries hold slugs; a target that does not exist yet is allowed and reported as missing.` +
|
|
439
|
+
(upcoming.length ? `\n\nNext events:\n${upcoming.map((e) => entityLine(EVENTS, e)).join("\n")}` : ""));
|
|
440
|
+
}
|
|
441
|
+
export { recordLine };
|
package/dist/core/git.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { realpathSync } from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
|
-
import { projectsRootFor, reposRootFor, resolveWorkspace, vibeablesRootFor, isSafeGitName } from "../config.js";
|
|
4
|
+
import { ENTITY_KINDS, entitiesRootFor, projectsRootFor, reposRootFor, resolveWorkspace, vibeablesRootFor, isSafeGitName } from "../config.js";
|
|
5
5
|
import { ENV_FILE } from "../runner/docker.js";
|
|
6
6
|
import { getOutputDir, workspaceRoot } from "./context.js";
|
|
7
7
|
import { perWorkspace, workspaceOpen } from "./workspace.js";
|
|
@@ -239,6 +239,8 @@ function scopeFor({ project, repoRoot }) {
|
|
|
239
239
|
vibeablesRootFor(project),
|
|
240
240
|
// Projects too: briefs, state and logs are the workspace's own record.
|
|
241
241
|
projectsRootFor(project),
|
|
242
|
+
// Contacts, places and events: who, where and when — the workspace's records.
|
|
243
|
+
...ENTITY_KINDS.map((kind) => entitiesRootFor(project, kind)),
|
|
242
244
|
// Workspace files: shared material, versioned (files over 25 MB ignore themselves, see files.ts).
|
|
243
245
|
path.resolve(project.root, "kraftwerk-data/files"),
|
|
244
246
|
project.configPath,
|