@mawaru/sdk 0.9.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.
@@ -107,8 +107,6 @@ export const humanConfigSchema = z.object({
107
107
  // どのキーが実在するか・そのテナントが選べるかは保存時に mawaru 側が検証する
108
108
  // (カタログは非公開。@mawaru/common の approval-view.ts)。省略時は標準ビュー
109
109
  view: z.string().min(1).optional(),
110
- // ビューに渡すパラメータ(列・ラベル・閾値など)。形はビューごとに違うので任意の JSON
111
- view_config: z.unknown().nullish(),
112
110
  assignment: z
113
111
  .object({
114
112
  strategy: z.enum(["fixed", "round_robin"]).default("fixed"),
@@ -409,49 +407,23 @@ export const saveGraphBodySchema = z
409
407
  message: "終了ノードからは接続できません",
410
408
  });
411
409
  }
412
- // human の reject の接続先は AI instruction ポートのみ。
413
- // 差し戻しは「元のセッションに指示を与える」操作で、上流データの口(in)とは
414
- // 意味が違う(docs/tasks/wip/ポートスキーマのany撲滅.md)
415
- if (fromNode.kind === "human" &&
416
- from.key === "reject" &&
417
- !(toNode.kind === "ai" && to.key === AI_INSTRUCTION_PORT_KEY)) {
418
- ctx.addIssue({
419
- code: "custom",
420
- path: ["connections", i, "to_port_id"],
421
- message: "差し戻し(reject)の接続先は AI ノードの instruction ポートのみです",
422
- });
423
- }
424
- // instruction ポートに繋げるのは human の reject だけ(データを流し込ませない)
425
- if (toNode.kind === "ai" &&
426
- to.key === AI_INSTRUCTION_PORT_KEY &&
427
- !(fromNode.kind === "human" && from.key === "reject")) {
428
- ctx.addIssue({
429
- code: "custom",
430
- path: ["connections", i, "to_port_id"],
431
- message: "instruction ポートに接続できるのは human の差し戻し(reject)だけです",
432
- });
433
- }
410
+ // 差し戻し(reject)と instruction の組み合わせは kind で縛らない:両端とも
411
+ // instructionSchema()({ prompt, files })で型が固定されているので、下の型検証だけで
412
+ // 「型が合う口にしか繋がらない」が成立する。差し戻し先が AI である必要はない
413
+ // ({ prompt, files } を受ける program へも繋げる)
414
+ // 型検証は kind によらず常に 伝播型(out) ⊆ 伝播型(in)。
415
+ // 未宣言は unknown=判断できないので通す(作りかけのグラフを保存できなくしない)。
416
+ // 接続鏡映する口(program / ai の in)とコピー宣言のビューは接続元の型を採用する
417
+ // ので自明に通り、実質の検証対象は宣言で固定された口
418
+ // (component の契約・ai の instruction・宣言のあるビューの human.in)になる
419
+ // docs/tasks/wip/Humanの入出力を承認ビューの宣言に一本化する.md
434
420
  const fromSchema = resolved.get(conn.from_port_id) ?? from.schema;
435
- // human の in はどの型でも接続できる(上流が AI なら封筒 { data, description }、
436
- // それ以外は input 全体を data として表示する。型制約は課さない)
437
- // program の in への接続は 伝播型 ⊆ 宣言スキーマ。in は接続鏡映(sticky)なので
438
- // 具体型の接続では自明に通り、接続元が未宣言 × 宣言維持のときだけ従来の意味を持つ
439
- // (未宣言は unknown=判断できないので通す。作りかけのグラフを保存できなくしない)
440
421
  const toSchema = resolved.get(conn.to_port_id) ?? to.schema;
441
- if (toNode.kind === "program" && !isSchemaSubset(fromSchema, toSchema)) {
442
- ctx.addIssue({
443
- code: "custom",
444
- path: ["connections", i, "to_port_id"],
445
- message: "接続元の型が program の入力スキーマに適合しません(伝播型 ⊆ 宣言スキーマ)",
446
- });
447
- }
448
- // component の in は子 loop の契約(焼き込み済み宣言)。program と同じ包含チェック
449
- if (toNode.kind === "component" &&
450
- !isSchemaSubset(fromSchema, toSchema)) {
422
+ if (!isSchemaSubset(fromSchema, toSchema)) {
451
423
  ctx.addIssue({
452
424
  code: "custom",
453
425
  path: ["connections", i, "to_port_id"],
454
- message: "接続元の型がコンポーネントの入力契約に適合しません(伝播型 ⊆ 契約)",
426
+ message: `「${fromNode.name}」の出力が「${toNode.name}」の入力スキーマに適合しません`,
455
427
  });
456
428
  }
457
429
  });
@@ -1,7 +1,7 @@
1
1
  import { z } from "zod";
2
2
  export declare const hookEventSchema: z.ZodEnum<{
3
- output: "output";
4
3
  input: "input";
4
+ output: "output";
5
5
  signal: "signal";
6
6
  }>;
7
7
  export type HookEvent = z.infer<typeof hookEventSchema>;
@@ -9,8 +9,8 @@ export declare const hookManifestSchema: z.ZodObject<{
9
9
  name: z.ZodOptional<z.ZodString>;
10
10
  description: z.ZodOptional<z.ZodString>;
11
11
  on: z.ZodArray<z.ZodEnum<{
12
- output: "output";
13
12
  input: "input";
13
+ output: "output";
14
14
  signal: "signal";
15
15
  }>>;
16
16
  env: z.ZodDefault<z.ZodArray<z.ZodString>>;
@@ -1,12 +1,12 @@
1
1
  import { z } from "zod";
2
2
  export declare const nodeKindSchema: z.ZodEnum<{
3
+ start: "start";
4
+ end: "end";
3
5
  ai: "ai";
4
6
  human: "human";
5
7
  program: "program";
6
8
  guardrail: "guardrail";
7
9
  wait: "wait";
8
- start: "start";
9
- end: "end";
10
10
  component: "component";
11
11
  }>;
12
12
  export type NodeKind = z.infer<typeof nodeKindSchema>;
@@ -62,8 +62,16 @@ type PortLink = {
62
62
  from_port_id: string;
63
63
  to_port_id: string;
64
64
  };
65
- export declare const resolvePortSchemas: <N extends PropagationNode>(nodes: N[], connections: PortLink[]) => Map<string, JsonSchema>;
66
- export declare const propagateDerivedPorts: <N extends PropagationNode>(nodes: N[], connections: PortLink[]) => N[];
65
+ export declare const COPY_UPSTREAM_SCHEMA = "T";
66
+ export type DeclaredPortSchema = JsonSchema | typeof COPY_UPSTREAM_SCHEMA;
67
+ export type PropagationOptions<N> = {
68
+ viewPortSchemasOf?: (node: N) => {
69
+ in: DeclaredPortSchema;
70
+ out: DeclaredPortSchema;
71
+ } | null | undefined;
72
+ };
73
+ export declare const resolvePortSchemas: <N extends PropagationNode>(nodes: N[], connections: PortLink[], options?: PropagationOptions<N>) => Map<string, JsonSchema>;
74
+ export declare const propagateDerivedPorts: <N extends PropagationNode>(nodes: N[], connections: PortLink[], options?: PropagationOptions<N>) => N[];
67
75
  export type SchemaFit = "ok" | "mismatch" | "unknown";
68
76
  export declare const schemaFit: (sub: JsonSchema, sup: JsonSchema) => SchemaFit;
69
77
  export declare const isSchemaSubset: (sub: JsonSchema, sup: JsonSchema) => boolean;
@@ -247,6 +247,9 @@ export const mergeInboundSchemas = (schemas) => {
247
247
  };
248
248
  // 流入の具体型が2種類以上あるか(=同じポートに違う型を流し込んでいる)
249
249
  export const hasInboundConflict = (schemas) => new Set(schemas.filter((s) => !isUndeclaredSchema(s)).map((s) => JSON.stringify(s))).size > 1;
250
+ // 「上流の out をそのままコピーする」(in)/「in と同型」(approve)を表す指示。
251
+ // 承認ビューのカタログ(@mawaru/common)の COPY_UPSTREAM と同じ値
252
+ export const COPY_UPSTREAM_SCHEMA = "T";
250
253
  // end も導出ノード(各 in は上流から、out main は未宣言)。個別処理は
251
254
  // resolvePortSchemas 内で行うが、初期スキーマを未宣言にするためここに含める。
252
255
  // start も導出ノード:out main は接続先の in から逆向きに転写し、in は out と同型
@@ -274,7 +277,7 @@ const constSchemaOf = (kind, port) => {
274
277
  };
275
278
  // port_id → 解決済みスキーマ。導出は接続を遡って固定点まで反復し、
276
279
  // 未接続・純パラメトリック閉路・fan-in の型割れは未宣言に落とす
277
- export const resolvePortSchemas = (nodes, connections) => {
280
+ export const resolvePortSchemas = (nodes, connections, options) => {
278
281
  const resolved = new Map();
279
282
  const kindByPortId = new Map();
280
283
  for (const node of nodes) {
@@ -346,17 +349,21 @@ export const resolvePortSchemas = (nodes, connections) => {
346
349
  assign(inPort.id, inSchema);
347
350
  continue;
348
351
  }
349
- assign(inPort.id, inSchema);
350
- // human approve:接続元が AI ノードなら封筒 { data, description } を剥がして
351
- // data 型を流す(承認は data だけを下流へ渡す)。それ以外の上流は従来どおり素通し
352
- const fromAiNode = sources.length === 1 &&
353
- sources[0] !== undefined &&
354
- kindByPortId.get(sources[0]) === "ai";
352
+ // 承認ビューの宣言を human のポートへそのまま転写する(上流ノードの kind は見ない)。
353
+ // "T" は「上流の out をコピー」(in)/「in と同型」(approve)。
354
+ // 上流の out は書き換えない:逆伝播すると fan-out 先のビューが食い違ったとき
355
+ // 型割れが config.json write-through され、元の出力宣言を黙って壊す。
356
+ // ズレは接続の型エラーで見せてユーザーの [修正する] で直す
357
+ const declared = options?.viewPortSchemasOf?.(node);
358
+ const effectiveIn = declared && declared.in !== COPY_UPSTREAM_SCHEMA
359
+ ? declared.in
360
+ : inSchema;
361
+ assign(inPort.id, effectiveIn);
355
362
  for (const port of node.ports) {
356
363
  if (port.io === "out" && isDerivedPort(node.kind, port)) {
357
- assign(port.id, node.kind === "human" && port.key === "approve" && fromAiNode
358
- ? (envelopeDataSchemaOf(inSchema) ?? UNDECLARED_SCHEMA)
359
- : inSchema);
364
+ assign(port.id, declared && declared.out !== COPY_UPSTREAM_SCHEMA
365
+ ? declared.out
366
+ : effectiveIn);
360
367
  }
361
368
  }
362
369
  }
@@ -367,8 +374,8 @@ export const resolvePortSchemas = (nodes, connections) => {
367
374
  };
368
375
  // ノード配列へ伝播結果を書き戻す(接続変更時のポートデータ自動更新)。
369
376
  // schema が変わらないポート・ノードは同じ参照を返す
370
- export const propagateDerivedPorts = (nodes, connections) => {
371
- const resolved = resolvePortSchemas(nodes, connections);
377
+ export const propagateDerivedPorts = (nodes, connections, options) => {
378
+ const resolved = resolvePortSchemas(nodes, connections, options);
372
379
  return nodes.map((node) => {
373
380
  let touched = false;
374
381
  const ports = node.ports.map((port) => {
package/dist/cli.js CHANGED
@@ -1,17 +1,29 @@
1
1
  #!/usr/bin/env node
2
2
  import { relative } from "node:path";
3
+ import { clearCredentials, loadCredentials } from "./_cli/credentials.js";
4
+ import { runLogin } from "./_cli/login.js";
5
+ import { runSessionResume } from "./_cli/sessionCommand.js";
3
6
  import { runInit } from "./init.js";
4
7
  import { runTypegen } from "./typegen.js";
5
8
  import { validateRepo } from "./validate.js";
6
- // 使い方: mawaru <init|typegen|validate> [repoルート](省略時はカレントディレクトリ)
7
- const USAGE = `使い方: mawaru <command> [repoルート]
9
+ // 使い方: mawaru <init|typegen|validate|login|logout|session> [引数]
10
+ const USAGE = `使い方: mawaru <command> [引数]
8
11
 
9
12
  コマンド:
10
13
  init repo を mawaru 実行用に初期化する(runner yml・nodes/ 骨組み・スキルポインタ等。再実行は冪等)
11
14
  typegen nodes/program/<dir>/config.json から types.d.ts(Input / Outputs / Output 型)を生成する
12
- validate リポジトリ規約を検証する(error があれば exit 1。CI 向け)`;
13
- const [command, rootArg] = process.argv.slice(2);
14
- const root = rootArg ?? process.cwd();
15
+ validate リポジトリ規約を検証する(error があれば exit 1。CI 向け)
16
+ login ブラウザで承認して mawaru にログインする(~/.mawaru/credentials.json)
17
+ logout 保存した資格情報を消す
18
+ session resume <step URL> [--yes]
19
+ AI ノードのセッションを手元に復元して claude --resume で再開する`;
20
+ const [command, ...rest] = process.argv.slice(2);
21
+ // init / typegen / validate は repo ルートを任意で受ける(省略時はカレント)
22
+ const root = rest[0] ?? process.cwd();
23
+ const fail = (message) => {
24
+ console.error(message);
25
+ process.exit(1);
26
+ };
15
27
  switch (command) {
16
28
  case "init": {
17
29
  const result = runInit(root);
@@ -47,6 +59,39 @@ switch (command) {
47
59
  process.exit(errorCount > 0 ? 1 : 0);
48
60
  break;
49
61
  }
62
+ case "login": {
63
+ const existing = loadCredentials();
64
+ if (existing)
65
+ console.log(`現在: ${existing.email}(上書きされます)`);
66
+ try {
67
+ const { email, path } = await runLogin();
68
+ console.log(`✔ ${email} としてログインしました(${path})`);
69
+ }
70
+ catch (e) {
71
+ fail(e instanceof Error ? e.message : String(e));
72
+ }
73
+ break;
74
+ }
75
+ case "logout": {
76
+ console.log(clearCredentials()
77
+ ? "ログアウトしました"
78
+ : "ログインしていません(消すものはありません)");
79
+ break;
80
+ }
81
+ case "session": {
82
+ const [subcommand, ...args] = rest;
83
+ if (subcommand !== "resume") {
84
+ fail("使い方: mawaru session resume <step URL> [--yes]");
85
+ }
86
+ try {
87
+ const code = await runSessionResume(args.find((a) => !a.startsWith("-")), { yes: args.includes("--yes") || args.includes("-y") });
88
+ process.exit(code);
89
+ }
90
+ catch (e) {
91
+ fail(e instanceof Error ? e.message : String(e));
92
+ }
93
+ break;
94
+ }
50
95
  default: {
51
96
  console.error(USAGE);
52
97
  process.exit(command === undefined || command === "--help" ? 0 : 1);
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export * from "./_schemas/ai-manifest.js";
2
+ export * from "./_schemas/duplicate-selection.js";
2
3
  export * from "./_schemas/extract-component.js";
3
4
  export * from "./_schemas/graph.js";
4
5
  export * from "./_schemas/hook-manifest.js";
package/dist/index.js CHANGED
@@ -2,6 +2,7 @@
2
2
  // 正はここ(npm 公開)で、@mawaru/common(private)が re-export して
3
3
  // backend / frontend が使う。CLI(typegen / validate)の実装は export しない
4
4
  export * from "./_schemas/ai-manifest.js";
5
+ export * from "./_schemas/duplicate-selection.js";
5
6
  export * from "./_schemas/extract-component.js";
6
7
  export * from "./_schemas/graph.js";
7
8
  export * from "./_schemas/hook-manifest.js";
package/dist/init.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
- // このファイルは dev では src/sdk/init.ts・配布物では dist/init.js に居る。
4
+ // このファイルは dev では src/packages/sdk/init.ts・配布物では dist/init.js に居る。
5
5
  // templates/ 等は package root 直下なので、package.json まで遡って解決する
6
6
  const packageRoot = () => {
7
7
  let dir = dirname(fileURLToPath(import.meta.url));
package/index.ts CHANGED
@@ -2,6 +2,7 @@
2
2
  // 正はここ(npm 公開)で、@mawaru/common(private)が re-export して
3
3
  // backend / frontend が使う。CLI(typegen / validate)の実装は export しない
4
4
  export * from "./_schemas/ai-manifest.js"
5
+ export * from "./_schemas/duplicate-selection.js"
5
6
  export * from "./_schemas/extract-component.js"
6
7
  export * from "./_schemas/graph.js"
7
8
  export * from "./_schemas/hook-manifest.js"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mawaru/sdk",
3
- "version": "0.9.0",
3
+ "version": "0.13.0",
4
4
  "description": "mawaru 実行 repo の開発 SDK。契約スキーマ(config.json 規約・ループ graph API)の正 + typegen / validate CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -36,6 +36,7 @@
36
36
  "build": "tsc -p tsconfig.build.json",
37
37
  "typecheck": "tsc --noEmit",
38
38
  "test": "vitest run",
39
- "test:watch": "vitest"
39
+ "test:watch": "vitest",
40
+ "release": "sh -c 'pnpm typecheck && pnpm test && npm version --no-git-tag-version ${1:-minor} && pnpm publish --access public --no-git-checks' release"
40
41
  }
41
42
  }
@@ -14,19 +14,33 @@ repo ルートの `.env` に以下の4変数が揃っているか確認する。
14
14
  セットアップまで面倒を見る**(ユーザーに手作業のセットアップを求めない):
15
15
 
16
16
  1. `.gitignore` に `.env` が入っていることを確認する。無ければ追加する(API キーをコミットさせない)。
17
- 2. `.env` を作成・追記する:
17
+ 2. `.env` を作成・追記する。**足りない変数は推測で埋めず、その場でユーザーに聞く**(下の
18
+ 「環境変数が足りないとき」の原則に従う):
18
19
  - `MAWARU_API_URL` — API のベース URL(例: `https://api.mawaru.ai`。ローカル開発は `http://localhost:9000`)
19
20
  - `MAWARU_APP_URL` — フロントの URL(例: `https://app.mawaru.ai`。ローカル開発は `http://localhost:3000`)
20
21
  - `MAWARU_TENANT_ID` — tenant の ID
21
22
  - `MAWARU_API_KEY` — tenant の API キー
22
- 接続先が本番かローカルかはユーザーに確認する。API キーは mawaru のテナント設定画面
23
- (`{MAWARU_APP_URL}/settings` の「API キー」。admin のみ)で発行・コピーできるので、
24
- ユーザーに案内して値を入力してもらう。**キーの値をチャット・ログ・コミットに出さない。**
23
+ 接続先が本番かローカルかはユーザーに確認する(URL 2つはこれで決まる)。API キーは mawaru
24
+ テナント設定画面(`{MAWARU_APP_URL}/settings` の「API キー」。admin のみ)で発行・コピーできるので、
25
+ 発行手順を案内したうえで `.env` に入れてもらう。**キーの値をチャット・ログ・コミットに出さない。**
25
26
  3. 疎通確認:`GET {MAWARU_API_URL}/tenants/{MAWARU_TENANT_ID}/loops` が 200 を返せば完了。
26
27
  401 なら API キー、404 なら tenant ID を疑う。
27
28
 
28
29
  認証は全リクエスト共通でヘッダ `x-api-key: $MAWARU_API_KEY`。
29
30
 
31
+ ## 環境変数が足りないとき(全体の原則)
32
+
33
+ このスキルの `.env` でも、ノードの `env` 宣言(program / ai の handler・setup.sh・prompt が使う
34
+ secret)でも、**足りない値を推測・生成・空文字で代用しない。作業を止めてユーザーに対話的に聞く。**
35
+
36
+ - 足りないものは**まとめて1回で聞く**:変数名・何に使う値か・どこで取れるか(画面・発行手順)・
37
+ 形式の例を添える。答えを待ってから作業を再開する(タイムアウトを承認扱いにしない)。
38
+ - **秘密の値はチャットに書かせない・出力しない**。`.env` へはユーザー自身の手で追記してもらう
39
+ (例: `echo 'MAWARU_API_KEY=xxxx' >> .env` を実行してもらう)。ノードの `env` 宣言分は
40
+ 実行 repo の Settings → Secrets and variables → Actions に登録してもらい、こちらは**名前だけ**扱う。
41
+ - 受け取ったら「効いていること」まで確かめてから次へ進む(`.env` なら疎通確認、
42
+ Actions secrets なら名前が config.json の `env` と一致していること)。
43
+
30
44
  ## 契約の読み方(最重要)
31
45
 
32
46
  **API のボディ形をこの文書は説明しない。正はこのパッケージ内の Zod スキーマなので、必ずソースを読むこと**:
@@ -62,6 +76,7 @@ repo との矛盾・使用中ノードの削除は 409。**graph PUT は冪等
62
76
  1. **設計**:要件から必要なノード(program / ai / human / wait)と流れを決め、ユーザーに一言で確認する。
63
77
  2. **repo 側の準備**:足りない program / ai は `nodes/program/<dir>/` / `nodes/ai/<dir>/` に作る
64
78
  (config.json → `npx mawaru typegen` → main.ts 実装 → `npx mawaru validate`)。
79
+ **AI ノードの決定論的な処理は prompt ではなく `setup.sh` に書く**(下記)。
65
80
  **作った・変えたものは GitHub へ push してから次へ進む**(mawaru は repo を GitHub 経由で
66
81
  読むため、未 push だと graph 保存の検証・書き戻しと食い違って 409 になる)。
67
82
  3. **ループ作成**:`POST loops`。
@@ -78,6 +93,35 @@ repo との矛盾・使用中ノードの削除は 409。**graph PUT は冪等
78
93
  6. **報告**:エディタ URL `{MAWARU_APP_URL}/loops/{loopId}/edit` をユーザーに渡し、
79
94
  見た目の確認と run はユーザーに委ねる。
80
95
 
96
+ ## AI ノード:決定論的な処理は prompt でなく setup.sh に書く
97
+
98
+ やることが決まっている準備=**外部 repo の clone / skill の持ち込み / 認証キーのファイル化 /
99
+ ツールの用意**は、prompt で AI にやらせず `nodes/ai/<dir>/setup.sh` に書く。prompt には
100
+ 「どこに何が置いてあるか」と、そこから先の判断だけを書く。
101
+
102
+ 理由:LLM にやらせるとトークンを食い flaky で、失敗が「セットアップの失敗」として明瞭に出ない。
103
+ skill の配置は Claude Code 起動前に完了している必要もある。
104
+
105
+ 規約(実行 repo 側。config.json への宣言は不要、ファイルの存在が正):
106
+
107
+ ```bash
108
+ # nodes/ai/<dir>/setup.sh
109
+ set -euo pipefail
110
+ git clone --depth 1 "https://x-access-token:${SOME_REPO_TOKEN}@github.com/owner/repo.git" workspace/repo
111
+ cp -r workspace/repo/.claude/skills/some-skill skills/ # copySkills の前に走るので自動発見に乗る
112
+ printf '%s' "$SOME_SA_KEY" > workspace/key.json
113
+ ```
114
+
115
+ - 実行順は `env 注入 → input.json 書き出し → setup.sh → copySkills → 本体(Claude Code)`。
116
+ `bash nodes/ai/<dir>/setup.sh "$INPUT_JSON"`(実行ビット不要)、cwd は checkout ルート。
117
+ - **入力は `$1`(JSON 文字列)か `input.json`** で参照できる(`jq` はランナー同梱)。
118
+ - **環境変数は config.json の `env` 宣言分だけ**が注入される。必要な secret は `env` に宣言し、
119
+ 実行 repo の Actions secrets に登録してもらう(→「環境変数が足りないとき」)。
120
+ - **受け渡しはファイルで**。別プロセスなので `export` は本体に引き継がれない。clone 先・キーは
121
+ ワークスペース内の既知パス(`workspace/` 配下を推奨)に置き、prompt / skill はそのパスを参照する。
122
+ - **非 0 exit は step failed**(本体は実行されない)。`set -euo pipefail` を付ける。
123
+ - program ノードにも同じ規約が効く(`nodes/program/<dir>/setup.sh`)。
124
+
81
125
  ## 注意
82
126
 
83
127
  - **graph PUT は全量置換**。既存ループを更新するときは、必ず先に `GET loops/{loopId}` で