@ikenga/contract 0.11.0 → 0.13.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.
@@ -0,0 +1,250 @@
1
+ // Ikenga skill-action frontmatter schema — the source-of-truth shape for an
2
+ // Atelier skill *action*'s YAML frontmatter (the block between the leading
3
+ // `---` fences of an `actions/*.md` file).
4
+ //
5
+ // The Rust loader in `royalti-io/ikenga` at
6
+ // `src-tauri/src/pkg/skill_actions.rs` mirrors this schema. Field changes MUST
7
+ // be made in lockstep with that Rust struct — same convention as
8
+ // manifest.ts ↔ manifest.rs.
9
+ //
10
+ // Every field below is justified in plans/atelier/06-skill-action-contract.md.
11
+
12
+ import { z } from 'zod';
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Identity & taxonomy (G-TAXONOMY)
16
+ // ---------------------------------------------------------------------------
17
+
18
+ /**
19
+ * The eight stateful domains plus the one stateless generic skill.
20
+ * `skill-core` is the ONLY legal `depends_on` target (one-way edge, G-04/E-14).
21
+ */
22
+ export const DomainEnum = z.enum([
23
+ 'tasks',
24
+ 'mail',
25
+ 'outbound',
26
+ 'sales',
27
+ 'finance',
28
+ 'content',
29
+ 'research',
30
+ 'strategy',
31
+ 'skill-core',
32
+ ]);
33
+ export type Domain = z.infer<typeof DomainEnum>;
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // UX modes (R5, E-11) — exactly five
37
+ // ---------------------------------------------------------------------------
38
+
39
+ /**
40
+ * How an action presents itself to the operator.
41
+ * - confirm : preview the planned effect, single yes/no, then run.
42
+ * - silent : run with no prompt (still subject to capability scopes).
43
+ * - form : collect `inputs_schema` from the operator before running.
44
+ * - streaming : run with live token/log streaming surfaced in the dock.
45
+ * - approve : run-THEN-pause on a produced draft; operator approves/edits
46
+ * /rejects the artifact before any external side effect commits
47
+ * (the draft-review gate, E-11).
48
+ */
49
+ export const UxModeEnum = z.enum([
50
+ 'confirm',
51
+ 'silent',
52
+ 'form',
53
+ 'streaming',
54
+ 'approve',
55
+ ]);
56
+ export type UxMode = z.infer<typeof UxModeEnum>;
57
+
58
+ // ---------------------------------------------------------------------------
59
+ // Run binding (R5, G-01) — how the action actually executes
60
+ // ---------------------------------------------------------------------------
61
+
62
+ /** chat_prompt: hand a templated prompt to the engine in the dock chat. */
63
+ const ChatPromptRun = z.object({
64
+ kind: z.literal('chat_prompt'),
65
+ // Prompt template; may interpolate validated `inputs` via {{var}}.
66
+ prompt: z.string().min(1),
67
+ });
68
+
69
+ /** sidecar: invoke a bundled CLI sidecar by its manifest-declared id. */
70
+ const SidecarRun = z.object({
71
+ kind: z.literal('sidecar'),
72
+ sidecar_id: z.string().min(1),
73
+ // Argv template; entries may interpolate validated `inputs`.
74
+ args: z.array(z.string()).default([]),
75
+ });
76
+
77
+ /** mcp_tool: call a tool on a pkg-declared MCP server. */
78
+ const McpToolRun = z.object({
79
+ kind: z.literal('mcp_tool'),
80
+ server_id: z.string().min(1),
81
+ tool: z.string().min(1),
82
+ });
83
+
84
+ export const RunBinding = z.discriminatedUnion('kind', [
85
+ ChatPromptRun,
86
+ SidecarRun,
87
+ McpToolRun,
88
+ ]);
89
+ export type RunBinding = z.infer<typeof RunBinding>;
90
+
91
+ // ---------------------------------------------------------------------------
92
+ // Triggers (R5) — absorbs the 41 legacy crons. An action may be reachable by
93
+ // more than one trigger; `manual` is the implicit default if none declared.
94
+ // ---------------------------------------------------------------------------
95
+
96
+ /** Operator-invoked from the command surface / dock. */
97
+ const ManualTrigger = z.object({
98
+ kind: z.literal('manual'),
99
+ });
100
+
101
+ /** Time-driven. `cron` is a standard 5-field crontab expression. */
102
+ const ScheduleTrigger = z.object({
103
+ kind: z.literal('schedule'),
104
+ cron: z.string().min(1),
105
+ // Optional human label surfaced in the schedule UI.
106
+ label: z.string().optional(),
107
+ });
108
+
109
+ /** Inbound HTTP trigger routed through the shell bridge. */
110
+ const WebhookTrigger = z.object({
111
+ kind: z.literal('webhook'),
112
+ // Stable path segment; the shell namespaces it under the pkg id.
113
+ path: z.string().min(1),
114
+ });
115
+
116
+ /** Reacts to an internal shell/domain event by name. */
117
+ const EventTrigger = z.object({
118
+ kind: z.literal('event'),
119
+ event: z.string().min(1),
120
+ });
121
+
122
+ export const Trigger = z.discriminatedUnion('kind', [
123
+ ManualTrigger,
124
+ ScheduleTrigger,
125
+ WebhookTrigger,
126
+ EventTrigger,
127
+ ]);
128
+ export type Trigger = z.infer<typeof Trigger>;
129
+
130
+ // ---------------------------------------------------------------------------
131
+ // Capabilities (G-08, R18/R19) — coarse permission grants an action requires.
132
+ // `sqlite` is the stateful-domain seam: state lives in the local ikenga.db,
133
+ // reached through the host dbQuery (SELECT-only) and dbExec (parameterized
134
+ // mutate) bridges. The *table scope* is NOT declared here — it is declared once
135
+ // at the pkg manifest as permissions["sqlite.tables"] and cross-checked at
136
+ // install time against the generated tables.json (the applied ikenga.db STRICT
137
+ // schema). The frontmatter only asserts "this action touches sqlite".
138
+ // ---------------------------------------------------------------------------
139
+
140
+ export const CapabilityEnum = z.enum([
141
+ 'sqlite', // host.dbQuery / host.dbExec against ikenga.db (R18/R19)
142
+ 'mcp', // call declared MCP tools
143
+ 'sidecar', // spawn declared sidecars
144
+ 'network', // outbound network
145
+ 'fs', // host filesystem (scoped by manifest)
146
+ 'secrets', // read Stronghold-vaulted secrets
147
+ 'chat', // drive the dock chat engine
148
+ ]);
149
+ export type Capability = z.infer<typeof CapabilityEnum>;
150
+
151
+ // ---------------------------------------------------------------------------
152
+ // Setup lifecycle (D-02, setup-lifecycle decision) — `setup` is an optional,
153
+ // well-known action on EVERY skill. It localises the skill per project by
154
+ // writing ${CLAUDE_PROJECT_DIR}/.atelier/<skill>/manifest.json. It runs IN
155
+ // CHAT, never as a form screen. Two modes:
156
+ // - ai_infer : the agent drafts the instance config from the repo/site, the
157
+ // operator confirms/edits in chat.
158
+ // - interview: the agent asks the operator a scripted set of questions.
159
+ // `template_version` carries a migrate path so an upgraded skill can migrate an
160
+ // older instance file forward.
161
+ // ---------------------------------------------------------------------------
162
+
163
+ export const SetupModeEnum = z.enum(['ai_infer', 'interview']);
164
+ export type SetupMode = z.infer<typeof SetupModeEnum>;
165
+
166
+ export const SetupSpec = z.object({
167
+ mode: SetupModeEnum,
168
+ // Bumped whenever the instance-file shape changes; drives the migrate path.
169
+ template_version: z.number().int().positive(),
170
+ // For ai_infer: where the agent should look to draft the instance config.
171
+ infer_sources: z.array(z.string()).optional(),
172
+ // For interview: ordered question ids the chat flow walks.
173
+ interview_questions: z.array(z.string()).optional(),
174
+ });
175
+ export type SetupSpec = z.infer<typeof SetupSpec>;
176
+
177
+ // ---------------------------------------------------------------------------
178
+ // ActionFrontmatter — the locked shape.
179
+ // ---------------------------------------------------------------------------
180
+
181
+ export const ActionFrontmatter = z
182
+ .object({
183
+ // --- identity ---
184
+ /** Stable action id, unique within the skill. kebab-case. */
185
+ name: z
186
+ .string()
187
+ .min(1)
188
+ .regex(/^[a-z][a-z0-9-]*$/, 'name must be kebab-case'),
189
+ /** One-line human description shown in the command surface. */
190
+ description: z.string().min(1),
191
+ /** Owning domain (G-TAXONOMY). */
192
+ domain: DomainEnum,
193
+
194
+ // --- presentation & io ---
195
+ /** How the action presents to the operator (R5/E-11). */
196
+ ux_mode: UxModeEnum,
197
+ /**
198
+ * JSON-Schema describing the action's inputs. Kept as an opaque object here
199
+ * (validated as JSON-Schema at the destination); `form`/`approve` modes
200
+ * render it, `chat_prompt` runs interpolate validated values from it.
201
+ */
202
+ inputs_schema: z.record(z.string(), z.unknown()).optional(),
203
+
204
+ // --- execution ---
205
+ /** What the action actually does (G-01). */
206
+ run: RunBinding,
207
+ /** How the action can be invoked. Empty ⇒ manual-only. */
208
+ triggers: z.array(Trigger).default([]),
209
+
210
+ // --- dependency & permissions ---
211
+ /**
212
+ * One-way dependency edge. The ONLY legal target is 'skill-core'
213
+ * (G-04/E-14); any other entry is rejected by the lint in
214
+ * 06-skill-action-contract.md §"depends_on lint". Modeled as a literal
215
+ * array so the schema itself refuses non-skill-core targets.
216
+ */
217
+ depends_on: z.array(z.literal('skill-core')).default([]),
218
+ /** Coarse capability grants this action needs (G-08, R18/R19). */
219
+ requires_capabilities: z.array(CapabilityEnum).default([]),
220
+
221
+ // --- lifecycle ---
222
+ /**
223
+ * Present ONLY on the well-known `setup` action. Omitted on every other
224
+ * action. Refined below.
225
+ */
226
+ setup: SetupSpec.optional(),
227
+ })
228
+ .strict()
229
+ .superRefine((fm, ctx) => {
230
+ // The `setup` block is allowed iff this IS the setup action.
231
+ if (fm.name === 'setup' && fm.setup === undefined) {
232
+ ctx.addIssue({
233
+ code: z.ZodIssueCode.custom,
234
+ path: ['setup'],
235
+ message: 'the "setup" action must declare a `setup` block',
236
+ });
237
+ }
238
+ if (fm.name !== 'setup' && fm.setup !== undefined) {
239
+ ctx.addIssue({
240
+ code: z.ZodIssueCode.custom,
241
+ path: ['setup'],
242
+ message: '`setup` block is only valid on the "setup" action',
243
+ });
244
+ }
245
+ // An action that runs SQL must declare the `sqlite` capability so the
246
+ // pkg-manifest sqlite.tables cross-check has something to bind to.
247
+ // (The reverse — declaring sqlite without using it — is allowed/harmless.)
248
+ });
249
+
250
+ export type ActionFrontmatter = z.infer<typeof ActionFrontmatter>;
package/src/index.ts CHANGED
@@ -8,6 +8,8 @@ export * from './artifact.js';
8
8
  export * from './registry.js';
9
9
  export * from './browser.js';
10
10
  export * from './pa-actions.js';
11
+ export * from './window.js';
12
+ export * from './action-frontmatter.js';
11
13
 
12
14
  /** This package's own version. */
13
15
  export const CONTRACT_PACKAGE_VERSION = '0.5.0' as const;
package/src/manifest.ts CHANGED
@@ -74,6 +74,11 @@ export const PermissionsSchema = z.object({
74
74
  * without errors during the transition window. */
75
75
  'supabase.tables': z.array(z.string()).default([]),
76
76
  'vault.keys': z.array(z.string()).default([]),
77
+ /** Engine scopes exercisable from the pkg iframe. `"invoke"` gates the
78
+ * FE-side host.sendToActiveSession / host.startChatSession verbs
79
+ * (pkgDeclaresScope). Mirrors `Permissions.engine` in the shell's
80
+ * manifest.rs — keep in lockstep. */
81
+ engine: z.array(z.string()).default([]),
77
82
  }).default({});
78
83
 
79
84
  export const NavEntrySchema = z.object({
package/src/pa-actions.ts CHANGED
@@ -36,6 +36,27 @@ export interface DraftSequence {
36
36
  recipients: number;
37
37
  }
38
38
 
39
+ /** One sub-post in an X/Twitter thread (X allows ≤4 images per tweet). */
40
+ export interface ThreadPost {
41
+ text: string;
42
+ /** Per-sub-post media URLs (X allows ≤4 images per tweet). */
43
+ imageUrls?: string[];
44
+ }
45
+
46
+ /**
47
+ * Media block carried on a Buffer DraftItem. `kind` selects the adapter path:
48
+ * - `'single'` — 0–1 image attached to the post body.
49
+ * - `'carousel'` — 2–10 images (IG ≤10, LI ≤9); adapter uses multi-asset upload.
50
+ * - `'thread'` — ordered sub-posts; adapter posts as N linked Buffer posts (≥2, ≤25).
51
+ */
52
+ export interface SocialMedia {
53
+ kind: 'single' | 'carousel' | 'thread';
54
+ /** single = 0–1 image URL; carousel = 2–10 (platform-dependent). */
55
+ imageUrls?: string[];
56
+ /** thread-kind only: ordered sub-posts (≥2, ≤25). */
57
+ thread?: ThreadPost[];
58
+ }
59
+
39
60
  /**
40
61
  * What an approve-aware action emits per draft — the lean, authoritative
41
62
  * producer shape. The shell derives the panel's `PausedDraft` from this via
@@ -88,6 +109,23 @@ export interface DraftItem {
88
109
  threadCount?: string;
89
110
  /** Explicit section bucket; otherwise derived from `scheduledIso`. */
90
111
  section?: string;
112
+ // ── Buffer / social fields (mirror of payload_json media contract) ──────────
113
+ /**
114
+ * Resolved Buffer channel id — company vs personal LinkedIn disambiguated at
115
+ * enqueue time (not in the adapter). Only set for `channel: 'buffer'` drafts.
116
+ */
117
+ channelId?: string;
118
+ /**
119
+ * First comment text for hashtags / blog URL. Avoids Buffer link-preview
120
+ * attachment errors on LinkedIn. Mapped to `metadata.<platform>.firstComment`.
121
+ */
122
+ firstComment?: string;
123
+ /**
124
+ * Social media block; selects single / carousel / thread adapter path.
125
+ * Kept in lockstep with `payload.schema.ts` `SocialMedia` Zod schema and
126
+ * the worker mirror in `scripts/cron/lib/channels/types.ts`.
127
+ */
128
+ media?: SocialMedia;
91
129
  }
92
130
 
93
131
  /**
@@ -0,0 +1,70 @@
1
+ import assert from 'node:assert/strict';
2
+ import { test } from 'node:test';
3
+ import {
4
+ WINDOW_CONTRACT_VERSION,
5
+ WindowDescriptorSchema,
6
+ WindowEventEnvelopeSchema,
7
+ } from './window.js';
8
+
9
+ // Canonical wire fixtures — the Rust round-trip test
10
+ // (src-tauri/src/window/descriptor.rs + events.rs) asserts the SAME JSON.
11
+ // If these drift, the lockstep contract is broken.
12
+
13
+ const DESCRIPTOR_FIXTURE = {
14
+ label: 'detached-1',
15
+ kind: 'single-surface',
16
+ surface_set: ['chat'],
17
+ project_id: null,
18
+ layout_key: 'detached-1',
19
+ };
20
+
21
+ const ENVELOPE_FIXTURE = {
22
+ v: 1,
23
+ topic: 'window://opened',
24
+ source_label: 'core',
25
+ target: { kind: 'window', label: 'detached-1' },
26
+ payload: { label: 'detached-1' },
27
+ };
28
+
29
+ test('WindowDescriptor parses the canonical fixture and round-trips', () => {
30
+ const parsed = WindowDescriptorSchema.parse(DESCRIPTOR_FIXTURE);
31
+ assert.deepEqual(parsed, DESCRIPTOR_FIXTURE);
32
+ // re-parse the serialized form (round-trip)
33
+ assert.deepEqual(
34
+ WindowDescriptorSchema.parse(JSON.parse(JSON.stringify(parsed))),
35
+ DESCRIPTOR_FIXTURE,
36
+ );
37
+ });
38
+
39
+ test('WindowDescriptor rejects unknown keys (deny_unknown_fields parity)', () => {
40
+ assert.throws(() =>
41
+ WindowDescriptorSchema.parse({ ...DESCRIPTOR_FIXTURE, rogue: true }),
42
+ );
43
+ });
44
+
45
+ test('WindowDescriptor applies defaults for surface_set/project_id', () => {
46
+ const parsed = WindowDescriptorSchema.parse({
47
+ label: 'main',
48
+ kind: 'primary',
49
+ layout_key: 'main',
50
+ });
51
+ assert.deepEqual(parsed.surface_set, []);
52
+ assert.equal(parsed.project_id, null);
53
+ });
54
+
55
+ test('WindowEventEnvelope parses the canonical fixture and round-trips', () => {
56
+ const parsed = WindowEventEnvelopeSchema.parse(ENVELOPE_FIXTURE);
57
+ assert.equal(parsed.v, WINDOW_CONTRACT_VERSION);
58
+ assert.deepEqual(
59
+ WindowEventEnvelopeSchema.parse(JSON.parse(JSON.stringify(parsed))),
60
+ ENVELOPE_FIXTURE,
61
+ );
62
+ });
63
+
64
+ test('WindowEventTarget broadcast variant parses', () => {
65
+ const parsed = WindowEventEnvelopeSchema.parse({
66
+ ...ENVELOPE_FIXTURE,
67
+ target: { kind: 'broadcast' },
68
+ });
69
+ assert.equal(parsed.target.kind, 'broadcast');
70
+ });
package/src/window.ts ADDED
@@ -0,0 +1,100 @@
1
+ // Multi-window contract — mirrors the Rust source-of-truth in
2
+ // `royalti-io/ikenga` at `src-tauri/src/window/{descriptor,events}.rs`.
3
+ //
4
+ // This is the `G-WINDOW-MODEL` freeze-gate output (plans/multi-window WP-02).
5
+ // It has TWO co-equal halves: the `WindowDescriptor` (what a window IS) and the
6
+ // cross-window event envelope (how windows talk over the shared Rust core).
7
+ // Both are imported by every downstream consumer (the window registry, the thin
8
+ // detached FE entry, Flavor C). Field changes MUST be made in lockstep with the
9
+ // Rust structs; bump WINDOW_CONTRACT_VERSION on any non-additive change.
10
+
11
+ import { z } from 'zod';
12
+
13
+ /** Envelope + descriptor wire version. Bumped on non-additive change. */
14
+ export const WINDOW_CONTRACT_VERSION = 1 as const;
15
+
16
+ // ---------- WindowDescriptor ----------
17
+
18
+ /**
19
+ * What a window is.
20
+ * - `primary` — the original "main" window (full chrome).
21
+ * - `single-surface` — a thin detached window hosting one surface (Flavor C).
22
+ * - `pane-set` — a torn-off pane group (Flavor A, Phase 2).
23
+ * - `workspace` — a second full workspace window (Flavor B, Phase 3).
24
+ */
25
+ export const WindowKindSchema = z.enum([
26
+ 'primary',
27
+ 'single-surface',
28
+ 'pane-set',
29
+ 'workspace',
30
+ ]);
31
+ export type WindowKind = z.infer<typeof WindowKindSchema>;
32
+
33
+ export const WindowDescriptorSchema = z
34
+ .object({
35
+ /** OS window label. "main" for the primary; "detached-<n>" otherwise. */
36
+ label: z.string().min(1),
37
+ kind: WindowKindSchema,
38
+ /** Surface/route ids the window's FE entry mounts. */
39
+ surface_set: z.array(z.string()).default([]),
40
+ /** Project this window is bound to (Flavor B); null = follows primary. */
41
+ project_id: z.string().nullable().default(null),
42
+ /**
43
+ * SQLite `layout_state` partition. A KEY-STRING suffix folded into
44
+ * `scopedKey()` (G-03) — NOT a new column. Usually equals `label`.
45
+ */
46
+ layout_key: z.string().min(1),
47
+ })
48
+ .strict();
49
+ export type WindowDescriptor = z.infer<typeof WindowDescriptorSchema>;
50
+
51
+ // ---------- Cross-window event envelope ----------
52
+
53
+ /**
54
+ * Where a window event goes. The DEFAULT for streaming/data channels is
55
+ * `broadcast` (every window filters its own subscription); only the channels
56
+ * that race (see WINDOW_TARGETED_CHANNELS) carry `window` targeting.
57
+ */
58
+ export const WindowEventTargetSchema = z.discriminatedUnion('kind', [
59
+ z.object({ kind: z.literal('broadcast') }).strict(),
60
+ z.object({ kind: z.literal('window'), label: z.string().min(1) }).strict(),
61
+ ]);
62
+ export type WindowEventTarget = z.infer<typeof WindowEventTargetSchema>;
63
+
64
+ /**
65
+ * The base shape every cross-window event rides. `payload` is opaque at the
66
+ * contract layer (each topic refines it); the envelope itself is what the
67
+ * registry + windows agree on.
68
+ */
69
+ export const WindowEventEnvelopeSchema = z
70
+ .object({
71
+ v: z.literal(WINDOW_CONTRACT_VERSION),
72
+ /** Channel name, e.g. "window://opened". */
73
+ topic: z.string().min(1),
74
+ /** Emitting window label, or "core" for the Rust core. */
75
+ source_label: z.string().min(1),
76
+ target: WindowEventTargetSchema,
77
+ payload: z.unknown(),
78
+ })
79
+ .strict();
80
+ export type WindowEventEnvelope = z.infer<typeof WindowEventEnvelopeSchema>;
81
+
82
+ /** Canonical window-lifecycle channels emitted by the Rust core. */
83
+ export const WINDOW_TOPICS = {
84
+ opened: 'window://opened',
85
+ closed: 'window://closed',
86
+ focusChanged: 'window://focus-changed',
87
+ } as const;
88
+ export type WindowTopic = (typeof WINDOW_TOPICS)[keyof typeof WINDOW_TOPICS];
89
+
90
+ /**
91
+ * Channels that MUST be window-targeted (`emit_to`) rather than broadcast,
92
+ * because a broadcast races across windows (first-reply-wins / wrong-window).
93
+ * Everything not listed here stays broadcast + per-window topic filtering.
94
+ * (plans/multi-window 03-research-internal: the ~25-channel routing table.)
95
+ */
96
+ export const WINDOW_TARGETED_CHANNELS = [
97
+ 'screenshot://request',
98
+ 'screenshot://shortcut',
99
+ 'projects:active-changed',
100
+ ] as const;