@tyhld/conductor 0.9.0 → 0.11.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,144 @@
1
+ /**
2
+ * 職人へ渡す指示文の【型】(試作完走 便⑥・えふさん確定 2026-09-05)。
3
+ *
4
+ * 【★この1本が正本】
5
+ * 司令塔が積む便の本文は、必ずここで組む。★他所で文字列を継ぎ足さない
6
+ * (2か所で組むと、片方だけ「URL を書いてください」を落として release 便が
7
+ * 静かに URL 無しで終わる——そして誰も気づけない)。
8
+ *
9
+ * 【★ここに書かないもの】
10
+ * 報告の書き方(REPORT_START/DONE/conductor_complete)は relay が送信のときに
11
+ * 必ず足す(src/relay.ts の buildSendText)。★ここで重ねて書かない。
12
+ *
13
+ * 【★試作の現場は本番の鍵を持たない(便⑧の型と対になる)】
14
+ * データの読み書きは試作の口(/api/proto/records)だけ。DDL も本番の鍵も渡さない。
15
+ * =人に確認を求める操作が、そもそも指示文の中に出てこない。
16
+ */
17
+ import { asList, } from './build-plan.js';
18
+ // ★試作の現場に置いてよい鍵の名前は、便⑧の正本(試作の型)が持つ。ここでは写さず引く。
19
+ import { PROTO_ENV_KEYS } from './proto-profile.js';
20
+ /** 段階ごとに、設計書のどの項目を渡すか(★全部渡すと読み飛ばされる)。 */
21
+ const FIELDS_BY_STAGE = {
22
+ design: ['goal', 'users', 'jobs_to_be_done', 'prototype_scope', 'out_of_scope', 'assumptions'],
23
+ data: ['entities', 'data_model', 'storage', 'permissions'],
24
+ screens: ['screens', 'user_flows', 'responsive_requirements'],
25
+ features: ['prototype_scope', 'user_flows', 'integrations', 'auth', 'permissions'],
26
+ testing: ['acceptance_criteria', 'test_scenarios'],
27
+ release: ['deployment_target', 'security_constraints', 'acceptance_criteria'],
28
+ };
29
+ /** 設計書の項目の言い換え(★お客さまが読むわけではないが、職人にも日本語で渡す)。 */
30
+ const FIELD_LABELS = {
31
+ goal: '目的',
32
+ users: '使う人',
33
+ jobs_to_be_done: '出来るようになること',
34
+ prototype_scope: '最初に作る機能',
35
+ out_of_scope: '今回は作らないこと',
36
+ acceptance_criteria: '完成条件',
37
+ screens: '画面の一覧',
38
+ user_flows: '使う人の動きの順番',
39
+ entities: '扱うものの種類',
40
+ data_model: 'それぞれが持つ項目',
41
+ permissions: 'だれが何をしてよいか',
42
+ integrations: 'つなぐ外のサービス',
43
+ auth: 'ログインの仕方',
44
+ storage: '写真やファイルの置き場',
45
+ responsive_requirements: 'スマホとPCのどちらで使うか',
46
+ security_constraints: '守ること',
47
+ test_scenarios: '動きを確かめる筋道',
48
+ deployment_target: 'どこに置いて動かすか',
49
+ assumptions: '仮に置いたこと',
50
+ };
51
+ /** release 便が報告に必ず書く行の頭(★司令塔がここから URL を読む)。 */
52
+ export const URL_LINE_PREFIX = 'URL:';
53
+ /** testing 便が報告に必ず書く行の頭(★司令塔がここから通った数を読む)。 */
54
+ export const ACCEPTANCE_LINE_PREFIX = 'ACCEPTANCE:';
55
+ /** 設計書の該当部分を1つの塊に整える。 */
56
+ function specSection(spec, stage) {
57
+ const lines = [];
58
+ for (const field of FIELDS_BY_STAGE[stage]) {
59
+ const items = asList(spec[field]);
60
+ if (items.length === 0)
61
+ continue;
62
+ lines.push(`・${FIELD_LABELS[field] ?? field}: ${items.join(' / ')}`);
63
+ }
64
+ return lines.length > 0 ? lines.join('\n') : '(設計書に該当する記載がありません。分かる範囲で進めてください)';
65
+ }
66
+ /** 前の便までの要点(★全文は渡さない。長いと今回やることが埋もれる)。 */
67
+ function previousSection(previous, deliveries, max = 5) {
68
+ const titles = new Map(deliveries.map((d) => [d.no, d.title]));
69
+ const rows = [...previous].sort((a, b) => b.no - a.no).slice(0, max).reverse();
70
+ if (rows.length === 0)
71
+ return '(この試作の最初の便です)';
72
+ return rows
73
+ .map((r) => `・${r.no}便「${titles.get(r.no) ?? ''}」: ${summarize(r.report)}`)
74
+ .join('\n');
75
+ }
76
+ /** 報告から要点だけ(★先頭の数行で足りる)。 */
77
+ function summarize(report, max = 200) {
78
+ const s = report.replace(/\s+/g, ' ').trim();
79
+ if (s === '')
80
+ return '報告なし';
81
+ return s.length > max ? `${s.slice(0, max)}…` : s;
82
+ }
83
+ /** 段階ごとに1行だけ足す約束(★release と testing は必ず読み取れる形で書かせる)。 */
84
+ function stageRule(stage) {
85
+ if (stage === 'release') {
86
+ return (`★この便は【公開】です。本番へ反映したあと、報告の中に必ず1行\n` +
87
+ ` ${URL_LINE_PREFIX} https://…(実際に触れるアドレス)\n` +
88
+ ` を書いてください(この1行から、お客さまの画面の「触ってみる」が作られます)。\n` +
89
+ ` ★公開先(Vercel の試作プロジェクト)がまだ用意されていないときは、無理に作らず\n` +
90
+ ` 「公開先が用意されていません」と報告し、outcome=blocked で終えてください。`);
91
+ }
92
+ if (stage === 'testing') {
93
+ return (`★この便は【受け入れテスト】です。設計書の完成条件を1つずつ確かめ、報告の中に必ず1行\n` +
94
+ ` ${ACCEPTANCE_LINE_PREFIX} 通った数/総数(例 ${ACCEPTANCE_LINE_PREFIX} 4/5)\n` +
95
+ ` を書いてください。通らなかった条件は、その理由を1行ずつ書いてください。`);
96
+ }
97
+ return '';
98
+ }
99
+ /**
100
+ * 便1本ぶんの指示文を組む。
101
+ *
102
+ * ★組み立ての順は「今回やること → 決まりごと → 設計書 → 前の便 → 締め方」。
103
+ * 職人が最初に読む数行で「何をするのか」が分かるようにする。
104
+ */
105
+ export function buildCraftsmanPrompt(input) {
106
+ const { delivery, site, projectDir, total, protoApiBase } = input;
107
+ const rule = stageRule(delivery.stage);
108
+ return [
109
+ `【試作 ${delivery.no}/${total}】${delivery.title}`,
110
+ '',
111
+ // ★積み直しのときだけ足す1段(1回目は行ごと出ない)。
112
+ ...(input.retryOf
113
+ ? [`★この便は積み直しです。前回はここで止まりました: ${summarize(input.retryOf, 500)}`, '']
114
+ : []),
115
+ '■ 冒頭にやること',
116
+ `git status && git log --oneline -3 && git stash list && git branch --show-current`,
117
+ `→ 最新の main から新しいブランチを切る。cd は1回だけ(${projectDir})。`,
118
+ `★作業対象は ${projectDir} のみ。ほかのフォルダを変更しない。`,
119
+ '',
120
+ '■ 今回やること',
121
+ delivery.title,
122
+ ...(rule !== '' ? [rule] : []),
123
+ '',
124
+ '■ この現場の決まり(★守る)',
125
+ `・データの読み書きは【試作の口だけ】を使う: ${protoApiBase.replace(/\/+$/, '')}/api/proto/records`,
126
+ ` (アドレスと合言葉は .env の ${PROTO_ENV_KEYS.join(' / ')} を読む。値をコードに書かない)`,
127
+ '・★この現場は本番のデータベースの鍵も、表を作り替える手立て(DDL)も持たない。',
128
+ ' 表を作る・移行する・本番の鍵を触る操作は【しない】(必要になったら、その旨を報告して止まる)。',
129
+ '・秘密(.env の中身)を画面にも報告にも出さない。',
130
+ '',
131
+ '■ 締め方',
132
+ '・テストが緑になってから push → プルリクエストを作成 → `gh pr merge <番号> --admin --merge --delete-branch`。',
133
+ '・報告は簡潔に(変更したファイル・要点・確かめた結果)。専門用語はできるだけ避ける。',
134
+ '・途中で人の判断が要るところに当たったら、無理に進めず outcome=blocked で終え、理由を1行で書く。',
135
+ '',
136
+ '■ 設計書(この便に関わる部分)',
137
+ specSection(input.spec, delivery.stage),
138
+ '',
139
+ '■ 前の便までの要点',
140
+ previousSection(input.previous, input.deliveries),
141
+ '',
142
+ `(現場: ${site})`,
143
+ ].join('\n');
144
+ }
@@ -0,0 +1,190 @@
1
+ /** 押さえ(円)。★確定した費用ではない(便④と同じ 30 円)。 */
2
+ const RESERVE_JPY = 30;
3
+ /** 会議1件で使えるトークンの上限(★累計の歯止め)。 */
4
+ const TOKEN_LIMIT = 200_000;
5
+ /** 周の数の上限(★この使い方では周は増えないが、中央の見回りのために届かない数を渡す)。 */
6
+ const MAX_ROUNDS = 99;
7
+ /** 議長(★1社だけに書かせる。並べて人に選ばせる画面を作らない)。 */
8
+ const CHAIR = 'anthropic';
9
+ const CHAIR_MODEL = 'anthropic-strong';
10
+ const TIMEOUT_MS = 120_000;
11
+ /**
12
+ * 合格条件(★「便の一覧として使える形か」だけを見る)。
13
+ *
14
+ * ★設計書の合格条件(19項目)とは別物。ここで見るのは並べ方の作法。
15
+ */
16
+ export function buildPlanCriteria() {
17
+ return [
18
+ '便の一覧が JSON(deliveries の配列)で書かれている',
19
+ '1便ずつに、お客さまがそのまま読める題名(title)が付いている',
20
+ '題名に専門用語(ブランチ・マイグレーション・デプロイ等)が入っていない',
21
+ '1便が30〜60分で終わる粒になっている',
22
+ 'それぞれの便に段階(stage)が design / data / screens / features / testing / release のどれかで付いている',
23
+ '最後の2便が、受け入れテスト(testing)と公開(release)になっている',
24
+ ];
25
+ }
26
+ /** 会議への頼み方(★system と user の型。ここが正本)。 */
27
+ export function buildPlanPrompt(args) {
28
+ const system = [
29
+ 'あなたは、非エンジニアのお客さま向けに試作(プロトタイプ)を作る段取りを組む担当です。',
30
+ '設計書を読み、AI の職人が1本ずつ受け取って進められる「便」の一覧を作ってください。',
31
+ '',
32
+ '守ること:',
33
+ '・出力は JSON だけ。説明文を付けない。形は {"deliveries":[{"no":1,"title":"…","stage":"design"}]}',
34
+ '・title は、お客さまがそのまま読む1行。専門用語(ブランチ・マイグレーション・デプロイ・CI 等)を使わない。',
35
+ '・stage は design / data / screens / features / testing / release のどれか。',
36
+ '・1便は30〜60分で終わる粒にする。',
37
+ '・並べる順は、そのまま作る順。前の便で出来た物の上に次を積む。',
38
+ '・★最後の2便は必ず、受け入れテスト(testing)→ 公開(release)にする。',
39
+ '・全部で20本を超えない。',
40
+ ].join('\n');
41
+ const user = [
42
+ '次の設計書から、便の一覧を作ってください。',
43
+ '',
44
+ '【設計書】',
45
+ JSON.stringify(args.spec, null, 2),
46
+ '',
47
+ '【完成条件】',
48
+ args.acceptanceCriteria.map((c, i) => `${i + 1}. ${c}`).join('\n') || '(記載なし)',
49
+ ].join('\n');
50
+ return { system, user };
51
+ }
52
+ function str(v) {
53
+ return typeof v === 'string' ? v.trim() : '';
54
+ }
55
+ /** 会議の口を1回叩く(★どんな失敗でも投げない)。 */
56
+ async function call(cfg, path, body, deps) {
57
+ const f = deps.fetch ?? ((u, i) => fetch(u, i));
58
+ const url = `${cfg.baseUrl.replace(/\/+$/, '')}/v1/ai-roundtable/roundtables${path}`;
59
+ try {
60
+ const res = await f(url, {
61
+ method: 'POST',
62
+ headers: { 'X-App-Key': cfg.appKey, 'Content-Type': 'application/json' },
63
+ ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
64
+ signal: AbortSignal.timeout(TIMEOUT_MS),
65
+ });
66
+ let data = null;
67
+ try {
68
+ const parsed = await res.json();
69
+ data = parsed && typeof parsed === 'object' ? parsed : null;
70
+ }
71
+ catch {
72
+ data = null;
73
+ }
74
+ return {
75
+ ok: res.ok,
76
+ status: res.status,
77
+ data,
78
+ error: str(data?.error) || 'error',
79
+ reason: str(data?.reason) || `http_${res.status}`,
80
+ message: str(data?.message),
81
+ };
82
+ }
83
+ catch (err) {
84
+ return {
85
+ ok: false,
86
+ status: 0,
87
+ data: null,
88
+ error: 'unavailable',
89
+ reason: 'central_unreachable',
90
+ message: `会議に繋がりませんでした(${err instanceof Error ? err.message : String(err)})`,
91
+ };
92
+ }
93
+ }
94
+ /**
95
+ * 会議に便の一覧を起草させる(★1回の呼び出しで、作る〜書かせるまで)。
96
+ *
97
+ * ★人待ち(awaiting_human)で断られたら、再開してもう一度だけ頼む(便④と同じ)。
98
+ */
99
+ export async function draftPlanByRoundtable(cfg, args, deps = {}) {
100
+ if (cfg.baseUrl.trim() === '' || cfg.appKey.trim() === '') {
101
+ return { ok: false, message: '会議の設定がありません。', reason: 'not_configured' };
102
+ }
103
+ const created = await call(cfg, '', {
104
+ title: args.title,
105
+ topic: '試作の便の一覧を決める',
106
+ tenant_id: args.tenantId,
107
+ max_rounds: MAX_ROUNDS,
108
+ token_limit: TOKEN_LIMIT,
109
+ }, deps);
110
+ if (!created.ok) {
111
+ return { ok: false, message: created.message || '会議を始められませんでした。', reason: `${created.error}/${created.reason}` };
112
+ }
113
+ const roundtableId = str(created.data?.roundtable?.id);
114
+ if (!roundtableId) {
115
+ return { ok: false, message: '会議を始められませんでした。', reason: 'roundtable_id_missing' };
116
+ }
117
+ // ★合格条件は作った直後に1回だけ(2回目は中央が 409 で断る=それは失敗ではない)。
118
+ const criteria = await call(cfg, `/${roundtableId}/criteria`, { criteria: buildPlanCriteria() }, deps);
119
+ if (!criteria.ok && criteria.reason !== 'criteria_already_confirmed') {
120
+ return {
121
+ ok: false,
122
+ message: criteria.message || '合格条件を決められませんでした。',
123
+ reason: `${criteria.error}/${criteria.reason}`,
124
+ };
125
+ }
126
+ const prompt = buildPlanPrompt({ spec: args.spec, acceptanceCriteria: args.acceptanceCriteria });
127
+ const first = await runRound(cfg, roundtableId, prompt, deps);
128
+ if (first.ok || !first.reason.includes('awaiting_human'))
129
+ return first;
130
+ // ★前の下書きで止まったままだった。再開して、もう一度だけ頼む。
131
+ const resumed = await call(cfg, `/${roundtableId}/human-actions`, { operation_id: randomId(), action: 'resume', target_kind: 'draft' }, deps);
132
+ if (!resumed.ok && resumed.reason !== 'operation_already_accepted') {
133
+ return {
134
+ ok: false,
135
+ message: resumed.message || '会議を再開できませんでした。',
136
+ reason: `${resumed.error}/${resumed.reason}`,
137
+ };
138
+ }
139
+ return runRound(cfg, roundtableId, prompt, deps);
140
+ }
141
+ /** 周を1つ進めて、議長が書いた文を取り出す。 */
142
+ async function runRound(cfg, roundtableId, prompt, deps) {
143
+ const res = await call(cfg, `/${roundtableId}/rounds`, {
144
+ // ★第0周(合格条件の段)。この使い方では審査の周を1度も回さない。
145
+ round_no: 0,
146
+ step_kind: 'criteria_draft',
147
+ output_kind: 'draft',
148
+ system: prompt.system,
149
+ user: prompt.user,
150
+ reserve_jpy: RESERVE_JPY,
151
+ models: { [CHAIR]: CHAIR_MODEL },
152
+ }, deps);
153
+ if (!res.ok) {
154
+ return { ok: false, message: res.message || '書けませんでした。', reason: `${res.error}/${res.reason}` };
155
+ }
156
+ // ★鍵がまだ無い(設定待ち)。中央は 200 で印を付けて返す。
157
+ if (str(res.data?.reason) === 'byok_not_ready') {
158
+ return { ok: false, message: str(res.data?.message) || 'AI の接続がまだ済んでいません。', reason: 'byok_not_ready' };
159
+ }
160
+ const content = readDraftContent(res.data?.results);
161
+ if (!content)
162
+ return { ok: false, message: 'うまく書けませんでした。', reason: 'empty_draft' };
163
+ return { ok: true, content };
164
+ }
165
+ /**
166
+ * 中央が返した結果から、議長が書いた文を取り出す。
167
+ * ★書くのは1社なので社では選ばない(成功した最初の1つを取る)。
168
+ */
169
+ export function readDraftContent(results) {
170
+ if (!Array.isArray(results))
171
+ return '';
172
+ for (const row of results) {
173
+ const o = (row ?? {});
174
+ if (o.ok === false)
175
+ continue;
176
+ const result = (o.result ?? {});
177
+ if (result.outcome !== 'succeeded')
178
+ continue;
179
+ const content = str(result.content);
180
+ if (content)
181
+ return content;
182
+ }
183
+ return '';
184
+ }
185
+ /** 操作の重複を防ぐ id(★中央が同じ操作を2回受けないための印)。 */
186
+ function randomId() {
187
+ return typeof globalThis.crypto?.randomUUID === 'function'
188
+ ? globalThis.crypto.randomUUID()
189
+ : `op-${Date.now()}-${Math.random().toString(16).slice(2)}`;
190
+ }