@mawaru/sdk 0.5.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.
Files changed (43) hide show
  1. package/_schemas/ai-manifest.test.ts +135 -0
  2. package/_schemas/ai-manifest.ts +76 -0
  3. package/_schemas/graph.test.ts +872 -0
  4. package/_schemas/graph.ts +439 -0
  5. package/_schemas/hook-manifest.test.ts +45 -0
  6. package/_schemas/hook-manifest.ts +25 -0
  7. package/_schemas/node.test.ts +76 -0
  8. package/_schemas/node.ts +54 -0
  9. package/_schemas/port-spec.test.ts +657 -0
  10. package/_schemas/port-spec.ts +405 -0
  11. package/_schemas/program-manifest.test.ts +126 -0
  12. package/_schemas/program-manifest.ts +43 -0
  13. package/dist/_schemas/ai-manifest.d.ts +25 -0
  14. package/dist/_schemas/ai-manifest.js +67 -0
  15. package/dist/_schemas/graph.d.ts +226 -0
  16. package/dist/_schemas/graph.js +372 -0
  17. package/dist/_schemas/hook-manifest.d.ts +18 -0
  18. package/dist/_schemas/hook-manifest.js +21 -0
  19. package/dist/_schemas/node.d.ts +47 -0
  20. package/dist/_schemas/node.js +42 -0
  21. package/dist/_schemas/port-spec.d.ts +62 -0
  22. package/dist/_schemas/port-spec.js +336 -0
  23. package/dist/_schemas/program-manifest.d.ts +10 -0
  24. package/dist/_schemas/program-manifest.js +41 -0
  25. package/dist/cli.d.ts +2 -0
  26. package/dist/cli.js +54 -0
  27. package/dist/index.d.ts +6 -0
  28. package/dist/index.js +9 -0
  29. package/dist/init.d.ts +6 -0
  30. package/dist/init.js +81 -0
  31. package/dist/typegen.d.ts +14 -0
  32. package/dist/typegen.js +206 -0
  33. package/dist/validate.d.ts +6 -0
  34. package/dist/validate.js +130 -0
  35. package/docs/development.md +59 -0
  36. package/index.ts +9 -0
  37. package/package.json +47 -0
  38. package/skills/create-loop/SKILL.md +87 -0
  39. package/templates/CLAUDE.md +23 -0
  40. package/templates/README.md +12 -0
  41. package/templates/echo/config.json +23 -0
  42. package/templates/echo/main.ts +4 -0
  43. package/templates/mawaru-runner.yml +71 -0
@@ -0,0 +1,405 @@
1
+ import { z } from "zod"
2
+ import type { GraphPort, JsonSchema, PortIo } from "./node.js"
3
+
4
+ // ポートの kind 別型システム(docs/tasks/wip/ポートのkind別仕様.md)。
5
+ // 型の決まり方は3分類:宣言(program / AI の in・out)/固定(human reject の
6
+ // content 型)/導出(それ以外。接続元から伝播し、結果は ports.schema にキャッシュする)
7
+
8
+ // content 型・未接続の導出ポートは any({})で表す。content の意味(エンジンが
9
+ // content に変換して注入する口)はスキーマではなく kind が決める
10
+ export const ANY_SCHEMA: JsonSchema = {}
11
+
12
+ export const isAnySchema = (schema: JsonSchema): boolean =>
13
+ Object.keys(schema).length === 0 || schema.type === undefined
14
+
15
+ // ---- file 型(AI ノードのファイル入力。docs/tasks/wip/AIノードのファイル入力(file型と添付).md) ----
16
+ // schema 上の file は x-mawaru-type マーカー付きの object(nominal に識別する)。値はバイト
17
+ // ではなく storage path の参照(url は startAiStep の dispatch 時に発行して client_payload に
18
+ // のみ載せる)。ajv は strict:false で未知キーワードを無視するので検証は素の JSON Schema のまま。
19
+ export const FILE_SCHEMA_MARKER = "x-mawaru-type"
20
+ export const FILE_SCHEMA_TYPE = "file"
21
+
22
+ export const fileSchema = (): JsonSchema => ({
23
+ type: "object",
24
+ [FILE_SCHEMA_MARKER]: FILE_SCHEMA_TYPE,
25
+ properties: {
26
+ path: { type: "string" },
27
+ name: { type: "string" },
28
+ mime_type: { type: "string" },
29
+ size: { type: "number" },
30
+ },
31
+ required: ["path", "name"],
32
+ })
33
+
34
+ export const isFileSchema = (schema: JsonSchema): boolean =>
35
+ schema[FILE_SCHEMA_MARKER] === FILE_SCHEMA_TYPE
36
+
37
+ // ---- データフォーマットカタログ ----
38
+ // human 承認 UI の表示・編集フォームの出し分けに使う(テキスト=string(markdown)/
39
+ // リスト=Array<Row>(列定義つき))。バリデーションには使わない(data は任意の型)
40
+
41
+ export const LIST_COLUMN_TYPES = [
42
+ "string",
43
+ "number",
44
+ "boolean",
45
+ "date",
46
+ ] as const
47
+ export const listColumnTypeSchema = z.enum(LIST_COLUMN_TYPES)
48
+ export type ListColumnType = z.infer<typeof listColumnTypeSchema>
49
+
50
+ export const listColumnSchema = z.object({
51
+ key: z.string().min(1),
52
+ name: z.string().min(1),
53
+ type: listColumnTypeSchema,
54
+ })
55
+ export type ListColumn = z.infer<typeof listColumnSchema>
56
+
57
+ export const dataFormatSchema = z.discriminatedUnion("format", [
58
+ z.object({ format: z.literal("text") }),
59
+ z.object({
60
+ format: z.literal("list"),
61
+ columns: z.array(listColumnSchema).min(1),
62
+ }),
63
+ ])
64
+ export type DataFormat = z.infer<typeof dataFormatSchema>
65
+
66
+ const columnTypeToJsonSchema = (type: ListColumnType): JsonSchema =>
67
+ type === "date" ? { type: "string", format: "date" } : { type }
68
+
69
+ const columnTypeFromJsonSchema = (
70
+ schema: JsonSchema,
71
+ ): ListColumnType | null => {
72
+ if (schema.type === "string") {
73
+ return schema.format === "date" ? "date" : "string"
74
+ }
75
+ if (schema.type === "number" || schema.type === "boolean") return schema.type
76
+ return null
77
+ }
78
+
79
+ // フォーマット → S の JSON Schema。列の表示名は title に載せる
80
+ export const dataSchemaOf = (format: DataFormat): JsonSchema => {
81
+ if (format.format === "text") return { type: "string" }
82
+ return {
83
+ type: "array",
84
+ items: {
85
+ type: "object",
86
+ properties: Object.fromEntries(
87
+ format.columns.map((col) => [
88
+ col.key,
89
+ { ...columnTypeToJsonSchema(col.type), title: col.name },
90
+ ]),
91
+ ),
92
+ required: format.columns.map((col) => col.key),
93
+ },
94
+ }
95
+ }
96
+
97
+ // S の JSON Schema → フォーマット(構造マッチの逆引き。カタログ外は null)
98
+ export const dataFormatOf = (schema: JsonSchema): DataFormat | null => {
99
+ if (schema.type === "string" && schema.format === undefined) {
100
+ return { format: "text" }
101
+ }
102
+ if (schema.type !== "array") return null
103
+ const items = schema.items as JsonSchema | undefined
104
+ if (items?.type !== "object") return null
105
+ const properties = (items.properties ?? {}) as Record<string, JsonSchema>
106
+ const columns: ListColumn[] = []
107
+ for (const [key, prop] of Object.entries(properties)) {
108
+ const type = columnTypeFromJsonSchema(prop)
109
+ if (!type) return null
110
+ columns.push({ key, name: (prop.title as string | undefined) ?? key, type })
111
+ }
112
+ if (columns.length === 0) return null
113
+ return { format: "list", columns }
114
+ }
115
+
116
+ // 封筒型 { data: T, description: string }。T は任意の JSON Schema、description は
117
+ // AI によるデータの説明(AI が必ず生成する)。AI の全出口に強制する
118
+ export const envelopeSchemaOf = (dataSchema: JsonSchema): JsonSchema => ({
119
+ type: "object",
120
+ properties: {
121
+ data: dataSchema,
122
+ description: { type: "string" },
123
+ },
124
+ required: ["data", "description"],
125
+ })
126
+
127
+ // 封筒型なら data のサブスキーマを返す(data キー必須の object。それ以外は null)
128
+ export const envelopeDataSchemaOf = (schema: JsonSchema): JsonSchema | null => {
129
+ if (schema.type !== "object") return null
130
+ const properties = (schema.properties ?? {}) as Record<string, JsonSchema>
131
+ const required = (schema.required ?? []) as string[]
132
+ const data = properties.data
133
+ if (!data || !required.includes("data")) return null
134
+ return data
135
+ }
136
+
137
+ // AI の出口として妥当な封筒型か:data と description: string の両方が必須
138
+ export const isAiEnvelopeSchema = (schema: JsonSchema): boolean => {
139
+ if (envelopeDataSchemaOf(schema) === null) return false
140
+ const properties = (schema.properties ?? {}) as Record<string, JsonSchema>
141
+ const required = (schema.required ?? []) as string[]
142
+ return (
143
+ properties.description?.type === "string" &&
144
+ required.includes("description")
145
+ )
146
+ }
147
+
148
+ // ---- kind 別ポート構成 ----
149
+ // human / wait は構成(数・key・io)が固定。program / ai は in 1つ+out 1つ以上
150
+ // (key 自由。複数 out=分岐。AIノードの複数出口(分岐).md)
151
+
152
+ const FIXED_PORT_KEYS: Record<string, { in: string[]; out: string[] }> = {
153
+ human: { in: ["in"], out: ["approve", "reject"] },
154
+ wait: { in: ["in"], out: ["main"] },
155
+ // start は in 1つ(run 入力契約)+ out main 1つの恒等パススルー
156
+ start: { in: ["in"], out: ["main"] },
157
+ }
158
+
159
+ const FREE_OUT_KINDS = new Set(["program", "ai"])
160
+
161
+ // kind 別のポート構成違反を返す(適合なら null)。in=1/out>=1 の構造ルールは
162
+ // graph.ts 側の既存チェックが担い、ここは key の固定だけ見る
163
+ export const kindPortsViolation = (
164
+ kind: string,
165
+ ports: Pick<GraphPort, "io" | "key">[],
166
+ ): string | null => {
167
+ // end は out が main 1つ固定・in は1つ以上(複数ルートを別々の in で集約する。
168
+ // 開始・終了ノードの定義.md)。in の key は自由(自動採番)
169
+ if (kind === "end") {
170
+ const outKeys = ports.filter((p) => p.io === "out").map((p) => p.key)
171
+ if (outKeys.length !== 1 || outKeys[0] !== "main") {
172
+ return `end の out ポートは "main" ちょうど1つです`
173
+ }
174
+ if (!ports.some((p) => p.io === "in")) {
175
+ return `end には in ポートが1つ以上必要です`
176
+ }
177
+ return null
178
+ }
179
+ const fixed = FIXED_PORT_KEYS[kind]
180
+ if (!fixed) {
181
+ // program / ai(自由 out):in の key だけ "in" 固定
182
+ if (
183
+ FREE_OUT_KINDS.has(kind) &&
184
+ ports.some((p) => p.io === "in" && p.key !== "in")
185
+ )
186
+ return `${kind} の in ポートの key は "in" です`
187
+ return null
188
+ }
189
+ const keysOf = (io: PortIo) =>
190
+ ports
191
+ .filter((p) => p.io === io)
192
+ .map((p) => p.key)
193
+ .sort()
194
+ const expect = (io: PortIo) => [...fixed[io]].sort()
195
+ if (
196
+ keysOf("in").join(",") !== expect("in").join(",") ||
197
+ keysOf("out").join(",") !== expect("out").join(",")
198
+ ) {
199
+ return `${kind} のポート構成は in(${fixed.in.join(",")}) / out(${fixed.out.join(",")}) 固定です`
200
+ }
201
+ return null
202
+ }
203
+
204
+ // ---- 型伝播(導出ポートの解決) ----
205
+ // 接続の両端は同じスキーマになる(値入力フォームのSchemaForm統一.md)。
206
+ // wait / human は素通し(パラメトリック)ノード:in は接続元の out、out は in と同型。
207
+ // 例外は human の approve:接続元が AI ノードのときだけ封筒を剥がして data 型になる。
208
+ // human の reject は content 固定(any)。宣言 out(program / ai)は触らない。
209
+ // program / ai の in は接続鏡映(sticky):接続元 out が具体型のときだけ上書きし、
210
+ // 未接続・fan-in・any は現スキーマ(宣言)を維持する(鏡映結果は repo の config.json
211
+ // へも書き戻される。docs/tasks/wip/ポート編集のrepo書き戻し同期.md)
212
+
213
+ type PropagationNode = {
214
+ id: string
215
+ kind: string
216
+ ports: GraphPort[]
217
+ }
218
+ type PortLink = { from_port_id: string; to_port_id: string }
219
+
220
+ // end も導出ノード(各 in は上流から、out main は any)。個別処理は
221
+ // resolvePortSchemas 内で行うが、初期スキーマを any にするためここに含める。
222
+ // start も導出ノード:out main は接続先の in から逆向きに転写し、in は out と同型
223
+ // (run 入力契約=接続先の入力宣言。開始ノードの入力契約を接続先から導出.md)
224
+ const DERIVED_KINDS = new Set(["wait", "human", "end", "start"])
225
+
226
+ // in を接続鏡映(sticky)するノード:接続元 out が具体型のときだけ上書き
227
+ const MIRROR_KINDS = new Set(["program", "ai"])
228
+
229
+ const isDerivedPort = (kind: string, port: Pick<GraphPort, "io" | "key">) =>
230
+ DERIVED_KINDS.has(kind) &&
231
+ (port.io === "in" || port.key === "main" || port.key === "approve")
232
+
233
+ // AI の in は nodes/ai/<dir>/config.json の inputs.in 由来の宣言型が初期値で、
234
+ // 接続時は program と同じ鏡映で接続元と同値に同期される(const 扱い廃止。
235
+ // 接続バリデーションの「ai の in は常に許可」は維持)
236
+ const isConstPort = (kind: string, port: Pick<GraphPort, "io" | "key">) =>
237
+ kind === "human" && port.io === "out" && port.key === "reject"
238
+
239
+ // port_id → 解決済みスキーマ。導出は接続を遡って固定点まで反復し、
240
+ // 未接続・複数流入・純パラメトリック閉路は any に落とす(MVP の割り切り)
241
+ export const resolvePortSchemas = <N extends PropagationNode>(
242
+ nodes: N[],
243
+ connections: PortLink[],
244
+ ): Map<string, JsonSchema> => {
245
+ const resolved = new Map<string, JsonSchema>()
246
+ const kindByPortId = new Map<string, string>()
247
+ for (const node of nodes) {
248
+ for (const port of node.ports) {
249
+ kindByPortId.set(port.id, node.kind)
250
+ resolved.set(
251
+ port.id,
252
+ isDerivedPort(node.kind, port) || isConstPort(node.kind, port)
253
+ ? ANY_SCHEMA
254
+ : port.schema,
255
+ )
256
+ }
257
+ }
258
+ // in ポートへの流入(差し戻しの fan-in があり得る。1本のときだけ型を採用)
259
+ const inbound = new Map<string, string[]>()
260
+ // out ポートからの流出(start の逆向き転写が接続先の in を引くのに使う)
261
+ const outbound = new Map<string, string[]>()
262
+ for (const conn of connections) {
263
+ const list = inbound.get(conn.to_port_id) ?? []
264
+ list.push(conn.from_port_id)
265
+ inbound.set(conn.to_port_id, list)
266
+ const outs = outbound.get(conn.from_port_id) ?? []
267
+ outs.push(conn.to_port_id)
268
+ outbound.set(conn.from_port_id, outs)
269
+ }
270
+
271
+ for (let i = 0; i < nodes.length + 1; i++) {
272
+ let changed = false
273
+ for (const node of nodes) {
274
+ const assign = (portId: string, schema: JsonSchema) => {
275
+ if (JSON.stringify(resolved.get(portId)) !== JSON.stringify(schema)) {
276
+ resolved.set(portId, schema)
277
+ changed = true
278
+ }
279
+ }
280
+ // start:out main は接続先 in の解決値を転写(逆向き導出)。fan-out は
281
+ // 非 any が1種類(JSON 等値)のときだけ採用し、未接続・型割れは any。
282
+ // in は out main と同型(恒等の向きを out→in に逆転。run 入力契約は
283
+ // 接続先の入力宣言から自動で決まる)
284
+ if (node.kind === "start") {
285
+ const inPort = node.ports.find((p) => p.io === "in")
286
+ const outMain = node.ports.find(
287
+ (p) => p.io === "out" && p.key === "main",
288
+ )
289
+ if (inPort && outMain) {
290
+ const targets = (outbound.get(outMain.id) ?? [])
291
+ .map((id) => resolved.get(id) ?? ANY_SCHEMA)
292
+ .filter((s) => !isAnySchema(s))
293
+ .map((s) => JSON.stringify(s))
294
+ const uniq = [...new Set(targets)]
295
+ const schema =
296
+ uniq.length === 1 && uniq[0] !== undefined
297
+ ? (JSON.parse(uniq[0]) as JsonSchema)
298
+ : ANY_SCHEMA
299
+ assign(outMain.id, schema)
300
+ assign(inPort.id, schema)
301
+ }
302
+ continue
303
+ }
304
+ // end:各 in を自分の単一上流から導出(複数ルートを別 in で集約)。
305
+ // out main は any 固定(ルートごとに型が違いうる。Loop-in-Loop で詰める)
306
+ if (node.kind === "end") {
307
+ for (const port of node.ports) {
308
+ if (port.io !== "in") continue
309
+ const srcs = inbound.get(port.id) ?? []
310
+ assign(
311
+ port.id,
312
+ srcs.length === 1 && srcs[0] !== undefined
313
+ ? (resolved.get(srcs[0]) ?? ANY_SCHEMA)
314
+ : ANY_SCHEMA,
315
+ )
316
+ }
317
+ continue
318
+ }
319
+ const isMirror = MIRROR_KINDS.has(node.kind)
320
+ if (!DERIVED_KINDS.has(node.kind) && !isMirror) continue
321
+ const inPort = node.ports.find((p) => p.io === "in")
322
+ if (!inPort) continue
323
+ const sources = inbound.get(inPort.id) ?? []
324
+ const inSchema =
325
+ sources.length === 1 && sources[0] !== undefined
326
+ ? (resolved.get(sources[0]) ?? ANY_SCHEMA)
327
+ : ANY_SCHEMA
328
+ // 鏡映(program)は具体型が流れてきたときだけ上書き(宣言を any で潰さない)
329
+ if (isMirror) {
330
+ if (!isAnySchema(inSchema)) assign(inPort.id, inSchema)
331
+ continue
332
+ }
333
+ assign(inPort.id, inSchema)
334
+ // human の approve:接続元が AI ノードなら封筒 { data, description } を剥がして
335
+ // data 型を流す(承認は data だけを下流へ渡す)。それ以外の上流は従来どおり素通し
336
+ const fromAiNode =
337
+ sources.length === 1 &&
338
+ sources[0] !== undefined &&
339
+ kindByPortId.get(sources[0]) === "ai"
340
+ for (const port of node.ports) {
341
+ if (port.io === "out" && isDerivedPort(node.kind, port)) {
342
+ assign(
343
+ port.id,
344
+ node.kind === "human" && port.key === "approve" && fromAiNode
345
+ ? (envelopeDataSchemaOf(inSchema) ?? ANY_SCHEMA)
346
+ : inSchema,
347
+ )
348
+ }
349
+ }
350
+ }
351
+ if (!changed) break
352
+ }
353
+ return resolved
354
+ }
355
+
356
+ // ノード配列へ伝播結果を書き戻す(接続変更時のポートデータ自動更新)。
357
+ // schema が変わらないポート・ノードは同じ参照を返す
358
+ export const propagateDerivedPorts = <N extends PropagationNode>(
359
+ nodes: N[],
360
+ connections: PortLink[],
361
+ ): N[] => {
362
+ const resolved = resolvePortSchemas(nodes, connections)
363
+ return nodes.map((node) => {
364
+ let touched = false
365
+ const ports = node.ports.map((port) => {
366
+ const schema = resolved.get(port.id) ?? port.schema
367
+ if (JSON.stringify(schema) === JSON.stringify(port.schema)) return port
368
+ touched = true
369
+ return { ...port, schema }
370
+ })
371
+ return touched ? { ...node, ports } : node
372
+ })
373
+ }
374
+
375
+ // ---- 接続バリデーション(伝播型 ⊆ 宣言スキーマ) ----
376
+ // 完全な JSON Schema 包含ではなく実用的な構造チェック:any は素通し、type 一致、
377
+ // object は宣言側 required の存在と型互換、array は items 互換
378
+
379
+ export const isSchemaSubset = (sub: JsonSchema, sup: JsonSchema): boolean => {
380
+ if (isAnySchema(sup) || isAnySchema(sub)) return true
381
+ // file は nominal:マーカー一致の file 同士だけ可(file → any は上の any 判定で許可)。
382
+ // 構造が同じだけの普通の object は file に繋がない/file は普通の object に繋がない
383
+ if (isFileSchema(sub) || isFileSchema(sup))
384
+ return isFileSchema(sub) && isFileSchema(sup)
385
+ if (sub.type !== sup.type) return false
386
+ if (sup.type === "object") {
387
+ const subProps = (sub.properties ?? {}) as Record<string, JsonSchema>
388
+ const subRequired = (sub.required ?? []) as string[]
389
+ const supProps = (sup.properties ?? {}) as Record<string, JsonSchema>
390
+ for (const key of (sup.required ?? []) as string[]) {
391
+ const subProp = subProps[key]
392
+ if (!subProp || !subRequired.includes(key)) return false
393
+ const supProp = supProps[key]
394
+ if (supProp && !isSchemaSubset(subProp, supProp)) return false
395
+ }
396
+ return true
397
+ }
398
+ if (sup.type === "array") {
399
+ const supItems = sup.items as JsonSchema | undefined
400
+ const subItems = sub.items as JsonSchema | undefined
401
+ if (!supItems || !subItems) return true
402
+ return isSchemaSubset(subItems, supItems)
403
+ }
404
+ return true
405
+ }
@@ -0,0 +1,126 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import { programManifestSchema } from "./program-manifest.js"
3
+
4
+ const stringSchema = { type: "string" }
5
+ const objectSchema = {
6
+ type: "object",
7
+ properties: { to: { type: "string" } },
8
+ required: ["to"],
9
+ }
10
+
11
+ describe("programManifestSchema", () => {
12
+ it("name / description / inputs / outputs を受け付ける", () => {
13
+ const manifest = programManifestSchema.parse({
14
+ name: "返信を送る",
15
+ description: "問い合わせへの返信メールを送信する",
16
+ inputs: { in: objectSchema },
17
+ outputs: { main: objectSchema, bounced: stringSchema },
18
+ })
19
+ expect(manifest.name).toBe("返信を送る")
20
+ expect(Object.keys(manifest.outputs)).toEqual(["main", "bounced"])
21
+ })
22
+
23
+ it("name / description / env は省略できる(env は [] が補われる)", () => {
24
+ const manifest = programManifestSchema.parse({
25
+ inputs: { in: objectSchema },
26
+ outputs: { main: objectSchema },
27
+ })
28
+ expect(manifest.name).toBeUndefined()
29
+ expect(manifest.description).toBeUndefined()
30
+ expect(manifest.env).toEqual([])
31
+ })
32
+
33
+ it("env(使用する secret の宣言)を受け付ける・非空文字列のみ", () => {
34
+ const manifest = programManifestSchema.parse({
35
+ inputs: { in: objectSchema },
36
+ outputs: { main: objectSchema },
37
+ env: ["SENTRY_TOKEN", "SLACK_WEBHOOK_URL"],
38
+ })
39
+ expect(manifest.env).toEqual(["SENTRY_TOKEN", "SLACK_WEBHOOK_URL"])
40
+ expect(() =>
41
+ programManifestSchema.parse({
42
+ inputs: { in: objectSchema },
43
+ outputs: { main: objectSchema },
44
+ env: [""],
45
+ }),
46
+ ).toThrow()
47
+ })
48
+
49
+ it('inputs は "in" の1件だけ(join 導入まで)', () => {
50
+ const outputs = { main: objectSchema }
51
+ expect(() => programManifestSchema.parse({ inputs: {}, outputs })).toThrow()
52
+ expect(() =>
53
+ programManifestSchema.parse({ inputs: { data: objectSchema }, outputs }),
54
+ ).toThrow()
55
+ expect(() =>
56
+ programManifestSchema.parse({
57
+ inputs: { in: objectSchema, extra: objectSchema },
58
+ outputs,
59
+ }),
60
+ ).toThrow()
61
+ })
62
+
63
+ it("outputs は1件以上", () => {
64
+ expect(() =>
65
+ programManifestSchema.parse({
66
+ inputs: { in: objectSchema },
67
+ outputs: {},
68
+ }),
69
+ ).toThrow()
70
+ })
71
+
72
+ it("スキーマ値がオブジェクトでないものは拒否する", () => {
73
+ expect(() =>
74
+ programManifestSchema.parse({
75
+ inputs: { in: "string" },
76
+ outputs: { main: objectSchema },
77
+ }),
78
+ ).toThrow()
79
+ })
80
+
81
+ it("空文字の name を拒否する", () => {
82
+ expect(() =>
83
+ programManifestSchema.parse({
84
+ name: "",
85
+ inputs: { in: objectSchema },
86
+ outputs: { main: objectSchema },
87
+ }),
88
+ ).toThrow()
89
+ })
90
+
91
+ it("refs(上流出力の参照の宣言)を受け付ける・省略時は {} が補われる", () => {
92
+ const manifest = programManifestSchema.parse({
93
+ inputs: { in: objectSchema },
94
+ outputs: { main: objectSchema },
95
+ refs: { reply_to: stringSchema },
96
+ })
97
+ expect(manifest.refs).toEqual({ reply_to: stringSchema })
98
+ const omitted = programManifestSchema.parse({
99
+ inputs: { in: objectSchema },
100
+ outputs: { main: objectSchema },
101
+ })
102
+ expect(omitted.refs).toEqual({})
103
+ })
104
+
105
+ it("refs の key はポート key 規則(^[a-z][a-z0-9_]*$)", () => {
106
+ for (const key of ["Reply-To", "1st", "reply to"]) {
107
+ expect(() =>
108
+ programManifestSchema.parse({
109
+ inputs: { in: objectSchema },
110
+ outputs: { main: objectSchema },
111
+ refs: { [key]: stringSchema },
112
+ }),
113
+ ).toThrow()
114
+ }
115
+ })
116
+
117
+ it("refs のスキーマ値がオブジェクトでないものは拒否する", () => {
118
+ expect(() =>
119
+ programManifestSchema.parse({
120
+ inputs: { in: objectSchema },
121
+ outputs: { main: objectSchema },
122
+ refs: { reply_to: "string" },
123
+ }),
124
+ ).toThrow()
125
+ })
126
+ })
@@ -0,0 +1,43 @@
1
+ import { z } from "zod"
2
+ import { jsonSchemaSchema, portKeySchema } from "./node.js"
3
+
4
+ // nodes/program/<dir>/config.json(リポジトリ規約 v2)。プログラム自身が入出力スキーマを宣言し、
5
+ // エディタが取り込んで ports を自動生成する(実行時の正は ports テーブルのまま。
6
+ // docs/tasks/wip/プログラムのリポジトリ規約v2(ディレクトリ+config.json).md)。
7
+ // inputs / outputs は「ポート key → JSON Schema」のマップ。将来 join(in 複数)を導入しても
8
+ // 形式が変わらないよう最初から複数形にしてある
9
+ export const programManifestSchema = z
10
+ .object({
11
+ // 表示名(省略時はディレクトリ名)
12
+ name: z.string().min(1).optional(),
13
+ description: z.string().optional(),
14
+ // 実行に必要な secret 名の宣言(runner が宣言分だけを子プロセスへ渡す。
15
+ // 実行repoの構造見直し)
16
+ env: z.array(z.string().min(1)).default([]),
17
+ inputs: z.record(z.string(), jsonSchemaSchema),
18
+ outputs: z.record(z.string(), jsonSchemaSchema),
19
+ // refs=ループ変数の定義の転写(key → 値から推定した JSON Schema)。
20
+ // loop(エディタ)が正で、このセクションはグラフ保存が一方向に上書きする生成物。
21
+ // handler 実装者が「refs.json に何が来るか」を repo 側で見るためにある。
22
+ // 値そのものは repo に書かない(上流出力の参照(refs).md)
23
+ refs: z.record(portKeySchema, jsonSchemaSchema).default({}),
24
+ })
25
+ .superRefine((manifest, ctx) => {
26
+ // グラフ側の既存制約(in ポートちょうど1つ・key "in" 固定)に整合させる
27
+ const inKeys = Object.keys(manifest.inputs)
28
+ if (inKeys.length !== 1 || inKeys[0] !== "in") {
29
+ ctx.addIssue({
30
+ code: "custom",
31
+ path: ["inputs"],
32
+ message: 'inputs は "in" の1件だけ宣言してください(join 導入まで)',
33
+ })
34
+ }
35
+ if (Object.keys(manifest.outputs).length < 1) {
36
+ ctx.addIssue({
37
+ code: "custom",
38
+ path: ["outputs"],
39
+ message: "outputs は1件以上宣言してください",
40
+ })
41
+ }
42
+ })
43
+ export type ProgramManifest = z.infer<typeof programManifestSchema>
@@ -0,0 +1,25 @@
1
+ import { z } from "zod";
2
+ export declare const AI_MODELS: readonly ["claude-opus-4-8", "claude-sonnet-5", "claude-haiku-4-5"];
3
+ export declare const aiModelSchema: z.ZodEnum<{
4
+ "claude-opus-4-8": "claude-opus-4-8";
5
+ "claude-sonnet-5": "claude-sonnet-5";
6
+ "claude-haiku-4-5": "claude-haiku-4-5";
7
+ }>;
8
+ export type AiModel = z.infer<typeof aiModelSchema>;
9
+ export declare const DEFAULT_AI_MODEL = "claude-opus-4-8";
10
+ export declare const aiManifestSchema: z.ZodObject<{
11
+ name: z.ZodOptional<z.ZodString>;
12
+ description: z.ZodOptional<z.ZodString>;
13
+ prompt: z.ZodString;
14
+ model: z.ZodDefault<z.ZodEnum<{
15
+ "claude-opus-4-8": "claude-opus-4-8";
16
+ "claude-sonnet-5": "claude-sonnet-5";
17
+ "claude-haiku-4-5": "claude-haiku-4-5";
18
+ }>>;
19
+ skills: z.ZodDefault<z.ZodArray<z.ZodString>>;
20
+ env: z.ZodDefault<z.ZodArray<z.ZodString>>;
21
+ verbose: z.ZodOptional<z.ZodBoolean>;
22
+ inputs: z.ZodRecord<z.ZodString, z.ZodRecord<z.ZodString, z.ZodUnknown>>;
23
+ outputs: z.ZodRecord<z.ZodString, z.ZodRecord<z.ZodString, z.ZodUnknown>>;
24
+ }, z.core.$strip>;
25
+ export type AiManifest = z.infer<typeof aiManifestSchema>;
@@ -0,0 +1,67 @@
1
+ import { z } from "zod";
2
+ import { jsonSchemaSchema } from "./node.js";
3
+ import { isAiEnvelopeSchema } from "./port-spec.js";
4
+ // AI ノードのモデル選択肢(既定は Opus:下書き品質が価値の中心。費用はユーザー持ち)
5
+ export const AI_MODELS = [
6
+ "claude-opus-4-8",
7
+ "claude-sonnet-5",
8
+ "claude-haiku-4-5",
9
+ ];
10
+ export const aiModelSchema = z.enum(AI_MODELS);
11
+ export const DEFAULT_AI_MODEL = "claude-opus-4-8";
12
+ // nodes/ai/<dir>/config.json(実行repoの構造見直し)。AI ノードの定義(prompt/model/skills/
13
+ // 入出力スキーマ/env)は repo 側が正で、エディタが取り込んで ports を自動生成する
14
+ // (実行時の正は ports テーブルのまま。プログラム規約 v2 と同じ関係)。
15
+ // env は実行に必要な secret 名の宣言(runner が宣言分だけを子プロセスへ渡す)
16
+ export const aiManifestSchema = z
17
+ .object({
18
+ // 表示名(省略時はディレクトリ名)
19
+ name: z.string().min(1).optional(),
20
+ description: z.string().optional(),
21
+ // repo に置く時点で完成品なので必須(DB 時代の「書きかけ空文字」は廃止)
22
+ prompt: z.string().min(1),
23
+ model: aiModelSchema.default(DEFAULT_AI_MODEL),
24
+ // 同 repo のトップレベル skills/ 配下のディレクトリ名
25
+ skills: z
26
+ .array(z
27
+ .string()
28
+ .regex(/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/, "skill 名はディレクトリ名(英数字始まり)で指定してください"))
29
+ .default([]),
30
+ env: z.array(z.string().min(1)).default([]),
31
+ // 実行中の経過(発話・ツール呼び出し)を Actions ログへ逐次出すデバッグフラグ。
32
+ // 通常時は config.json にキーを書かない(インスペクタも false ならキーを消す)
33
+ verbose: z.boolean().optional(),
34
+ inputs: z.record(z.string(), jsonSchemaSchema),
35
+ outputs: z.record(z.string(), jsonSchemaSchema),
36
+ })
37
+ .superRefine((manifest, ctx) => {
38
+ // グラフ側の既存制約(in ちょうど1つ・key "in")に整合させる
39
+ const inKeys = Object.keys(manifest.inputs);
40
+ if (inKeys.length !== 1 || inKeys[0] !== "in") {
41
+ ctx.addIssue({
42
+ code: "custom",
43
+ path: ["inputs"],
44
+ message: 'inputs は "in" の1件だけ宣言してください(join 導入まで)',
45
+ });
46
+ }
47
+ // outputs は1件以上・key 自由(複数出口=分岐。AIノードの複数出口(分岐).md)
48
+ if (Object.keys(manifest.outputs).length < 1) {
49
+ ctx.addIssue({
50
+ code: "custom",
51
+ path: ["outputs"],
52
+ message: "outputs は1件以上宣言してください",
53
+ });
54
+ }
55
+ // Human 承認 UI が依存する封筒型 { data: T, description: string } を全出口に強制
56
+ // (どの出口も human に接続できる、という AI ノードの不変条件。saveGraph と同じ判定。
57
+ // data=T は任意の JSON Schema)
58
+ for (const [key, schema] of Object.entries(manifest.outputs)) {
59
+ if (!isAiEnvelopeSchema(schema)) {
60
+ ctx.addIssue({
61
+ code: "custom",
62
+ path: ["outputs", key],
63
+ message: `outputs.${key} は封筒型 { data, description } で宣言してください(両方必須・description は string)`,
64
+ });
65
+ }
66
+ }
67
+ });