@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.
package/README.md CHANGED
@@ -233,6 +233,66 @@ systemctl --user disable --now conductor-ear
233
233
 
234
234
  ---
235
235
 
236
+ ## 司令塔(conductor build-runner)— このPCに1本の試作の段取り役
237
+
238
+ `conductor build-runner` は、お客さまが管制で「この内容で試作する →」を押した設計書を拾い、
239
+ **便(作業指示)に分けて管制へ1本ずつ積み、終わるまで見届ける**常駐です(試作完走 便⑥)。
240
+ ねらいは「押したあと、人を止めない」こと。
241
+
242
+ | | conductor@<現場> | conductor-ear | **conductor-build-runner** |
243
+ |---|---|---|---|
244
+ | 本数 | 現場ごとに1本 | PCごとに1本 | **PCごとに1本** |
245
+ | 役目 | 指示の配達・完了検出 | 合図の前倒し | **指示を作って積む** |
246
+ | 職人(tmux)への書き込み | する | しない | **しない** |
247
+
248
+ ### 流れ
249
+
250
+ ```
251
+ ① 探す GET /api/proto/specs?status=approved&built=false (60秒ごと+合図があれば即)
252
+ ② 計画 会議(ai-roundtable)に便の一覧を起草させる
253
+ ★返らなければ設計書(画面・データ・機能)から機械的に組む
254
+ ③ 書く POST /api/proto/records collection='_build' kind='plan'
255
+ ★計画を書いた時点で①から外れる=同じ試作を二度始めない
256
+ ④ 積む POST /api/conductor/commands/enqueue(gate=white・現場 ai-kana-proto)
257
+ ★同時1本。前の便が終わるまで次を積まない
258
+ ⑤ 待つ GET /commands/done(結末・報告)/GET /commands/:id(時間切れ等)//commands/orphans
259
+ ⑥ 書く POST /api/proto/records kind='progress'(何本目・終わった数・結末・URL・通った数)
260
+ ⑦ 直す 詰まったら同じ便を積み直す(最大3回)。超えたら止めて人へ回す
261
+ ⑧ 締め 公開の便の報告の `URL:` から触れる場所、動作確認の便の `ACCEPTANCE: n/m` から通った数
262
+ ```
263
+
264
+ - **進み具合の正は中央**(`'_build'` に書いたもの)。この常駐が手元に持つのは
265
+ 「いまどの試作の何本目を待っているか」だけで、置き場は `~/.local/state/conductor/build-runner.json`(600)。
266
+ - **落ちても続きから**。起動時に `GET /api/proto/builds/:projectId` で計画と進み具合を読み直します。
267
+ 手元の覚え書きが消えていても、**積む前に必ず管制へ確かめる**ので同じ便を二度積みません。
268
+ - **会議が使えなくても止まりません**(設計書から組み立てて進みます)。
269
+ - 記録(`journalctl`)には**型番も鍵も出しません**(`redact` が伏せます)。
270
+
271
+ ### 導入(★人が明示的に入れます)
272
+
273
+ お客さま機の `setup.sh` からは入りません。試作を回す機だけに人が入れます。
274
+
275
+ ```bash
276
+ # ~/.conductor.env に4つ足す(値は弊社からお渡しします)
277
+ # PROTO_API_BASE=https://ai-kana.ty-hld.com
278
+ # CONDUCTOR_PROTO_APP_KEY=… ★司令塔専用の合言葉(試作アプリのものとは別)
279
+ # ROUNDTABLE_BASE_URL=…
280
+ # ROUNDTABLE_APP_KEY=…
281
+
282
+ bash systemd/build-runner-install.sh
283
+ journalctl --user -u conductor-build-runner -f
284
+ systemctl --user disable --now conductor-build-runner # やめる
285
+ ```
286
+
287
+ - 4つが揃っていなければ**設置台本が入れずに止まり**、起動しても**何が足りないかを言って止まります**
288
+ (黙って動かないのがいちばん困るため)。
289
+ - 配備先は **Vercel の試作プロジェクト**(人が `vercel.com/new` で作り、`main` への push で自動配備)。
290
+ 用意されていなければ公開の便がその旨を報告し、司令塔は「公開先の準備が要ります」で止めて人へ回します。
291
+ - ユニットの**正本は `systemd/conductor-build-runner.service` の1つだけ**(耳と同じ作法)。
292
+ `setup.sh` の掃除の例外(`CURRENT_MACHINE_UNITS`)にも入れてあります。
293
+
294
+ ---
295
+
236
296
  ## CI(GitHub Actions)
237
297
 
238
298
  `main` への push と、`main` を base とする pull request で `.github/workflows/ci.yml` が回ります。
@@ -0,0 +1,185 @@
1
+ /**
2
+ * 司令塔が外と話す部分(試作完走 便⑥・えふさん確定 2026-09-05)。
3
+ *
4
+ * 話す相手は3つだけ:
5
+ * ① 試作の口(ai-kana)… 次に作るものを探す/どこまで作ったかを拾う/進み具合を書く
6
+ * ② 管制(devlog-tracker)… 便を積む/終わったかを見る
7
+ * ③ 会議(ai-roundtable)… 便の一覧を起草させる(src/build-roundtable.ts)
8
+ *
9
+ * 【★鍵はここに書かない】
10
+ * 合言葉もトークンも、呼ぶ側(常駐)が env から読んで渡す。★この1本は env を読まない。
11
+ *
12
+ * 【★fetch を差し替えられる形にする】
13
+ * 司令塔の判断(積み直し3回・二重に積まない・URL の取り出し)を試すのに、
14
+ * 本物の中央は要らない。差し替え口を1つ持たせておけば、テストは通信ゼロで書ける。
15
+ *
16
+ * 【断られ方をそのまま運ぶ】
17
+ * 中央は断るとき日本語の message を返す。★「失敗しました」に丸めない
18
+ * (丸めると、何をすれば直るのかが誰にも分からなくなる)。
19
+ */
20
+ /** 通信の待ち時間の上限(ms)。★無応答で常駐が固まらないようにする。 */
21
+ const TIMEOUT_MS = 20_000;
22
+ function trimUrl(u) {
23
+ return u.replace(/\/+$/, '');
24
+ }
25
+ /** 中央(管制)へ出すヘッダ。 */
26
+ function bearer(cfg) {
27
+ return { Authorization: `Bearer ${cfg.token}` };
28
+ }
29
+ /** 試作の口へ出すヘッダ(★司令塔専用の合言葉)。 */
30
+ function appKey(cfg) {
31
+ return { 'X-App-Key': cfg.protoAppKey };
32
+ }
33
+ async function readJson(res) {
34
+ try {
35
+ return await res.json();
36
+ }
37
+ catch {
38
+ return null;
39
+ }
40
+ }
41
+ function str(v) {
42
+ return typeof v === 'string' ? v.trim() : '';
43
+ }
44
+ /** 実際に通信する一式を作る。 */
45
+ export function createCentralPorts(cfg, deps = {}) {
46
+ const call = deps.fetch ?? ((u, i) => fetch(u, i));
47
+ const log = deps.log ?? (() => { });
48
+ /** 1回ぶんの通信(★どんな失敗でも投げない。常駐を1回の不通で止めない)。 */
49
+ async function request(url, init) {
50
+ try {
51
+ const res = await call(url, { ...init, signal: AbortSignal.timeout(TIMEOUT_MS) });
52
+ const data = await readJson(res);
53
+ if (!res.ok) {
54
+ const message = str(data?.message);
55
+ const error = str(data?.error);
56
+ log(`HTTP ${res.status} ${url.split('?')[0]}${message ? ` — ${message}` : error ? ` — ${error}` : ''}`);
57
+ }
58
+ return { ok: res.ok, status: res.status, data };
59
+ }
60
+ catch (err) {
61
+ log(`通信できませんでした(${err instanceof Error ? err.message : String(err)})。次の回にやり直します`);
62
+ return { ok: false, status: 0, data: null };
63
+ }
64
+ }
65
+ const proto = trimUrl(cfg.protoApiBase);
66
+ const central = trimUrl(cfg.url);
67
+ return {
68
+ async listSpecs(limit) {
69
+ const r = await request(`${proto}/api/proto/specs?status=approved&built=false&limit=${limit}`, {
70
+ method: 'GET',
71
+ headers: appKey(cfg),
72
+ });
73
+ if (!r.ok)
74
+ return [];
75
+ const items = r.data?.items;
76
+ if (!Array.isArray(items))
77
+ return [];
78
+ const out = [];
79
+ for (const raw of items) {
80
+ const o = (raw ?? {});
81
+ const projectId = str(o.projectId);
82
+ const tenantId = str(o.tenantId);
83
+ if (!projectId || !tenantId)
84
+ continue; // ★名札の無い件は触らない。
85
+ out.push({
86
+ projectId,
87
+ tenantId,
88
+ specId: str(o.specId),
89
+ version: typeof o.version === 'number' ? o.version : 0,
90
+ spec: (o.spec ?? {}),
91
+ acceptanceCriteria: Array.isArray(o.acceptanceCriteria)
92
+ ? o.acceptanceCriteria.filter((x) => typeof x === 'string')
93
+ : [],
94
+ });
95
+ }
96
+ return out;
97
+ },
98
+ async getBuild(projectId) {
99
+ const r = await request(`${proto}/api/proto/builds/${encodeURIComponent(projectId)}`, {
100
+ method: 'GET',
101
+ headers: appKey(cfg),
102
+ });
103
+ // ★404 は「まだ1行も書いていない」=計画からやり直す合図(口の約束)。
104
+ if (!r.ok)
105
+ return null;
106
+ const d = (r.data ?? {});
107
+ return { plan: d.plan ?? null, progress: d.progress ?? null };
108
+ },
109
+ async writeBuildRecord(projectId, data) {
110
+ const r = await request(`${proto}/api/proto/records`, {
111
+ method: 'POST',
112
+ headers: { ...appKey(cfg), 'Content-Type': 'application/json' },
113
+ body: JSON.stringify({ projectId, collection: '_build', data }),
114
+ });
115
+ return r.ok;
116
+ },
117
+ async enqueue(body) {
118
+ const r = await request(`${central}/api/conductor/commands/enqueue`, {
119
+ method: 'POST',
120
+ headers: { ...bearer(cfg), 'Content-Type': 'application/json' },
121
+ body: JSON.stringify({ machine: cfg.machine, site: cfg.site, body, gate: 'white' }),
122
+ });
123
+ if (!r.ok)
124
+ return null;
125
+ const id = str(r.data?.id);
126
+ return id || null;
127
+ },
128
+ async listDone() {
129
+ const r = await request(`${central}/api/conductor/commands/done` +
130
+ `?machine=${encodeURIComponent(cfg.machine)}&site=${encodeURIComponent(cfg.site)}&limit=20`, { method: 'GET', headers: bearer(cfg) });
131
+ if (!r.ok || !Array.isArray(r.data))
132
+ return [];
133
+ return r.data.map((raw) => {
134
+ const o = (raw ?? {});
135
+ return {
136
+ id: str(o.id),
137
+ report: str(o.report),
138
+ outcome: typeof o.outcome === 'string' ? o.outcome : null,
139
+ };
140
+ });
141
+ },
142
+ async markOrchestrated(id) {
143
+ await request(`${central}/api/conductor/commands/${encodeURIComponent(id)}`, {
144
+ method: 'PATCH',
145
+ headers: { ...bearer(cfg), 'Content-Type': 'application/json' },
146
+ body: JSON.stringify({ orchestrated: true }),
147
+ });
148
+ },
149
+ async commandStatus(id) {
150
+ const r = await request(`${central}/api/conductor/commands/${encodeURIComponent(id)}`, {
151
+ method: 'GET',
152
+ headers: bearer(cfg),
153
+ });
154
+ if (!r.ok)
155
+ return null;
156
+ const d = r.data;
157
+ const raw = str(d?.status) || str(d?.command?.status);
158
+ return raw ? raw.toLowerCase() : null;
159
+ },
160
+ async hasInFlight() {
161
+ // ★「まだ配られていない便(queued)」があるか。積む前に必ず見る=同じ便を二度積まない。
162
+ // ★配達中(sent)の便は、管制がこの口で queued を返さないので、ここで false になる。
163
+ // それを拾うのが下の孤児の口(3分以上 sent のまま)。
164
+ const queued = await request(`${central}/api/conductor/commands` +
165
+ `?machine=${encodeURIComponent(cfg.machine)}&site=${encodeURIComponent(cfg.site)}`, { method: 'GET', headers: bearer(cfg) });
166
+ if (queued.ok) {
167
+ const list = Array.isArray(queued.data)
168
+ ? queued.data
169
+ : (queued.data?.queued ?? []);
170
+ if (Array.isArray(list) && list.length > 0)
171
+ return true;
172
+ }
173
+ const orphans = await request(`${central}/api/conductor/commands/orphans` +
174
+ `?machine=${encodeURIComponent(cfg.machine)}&site=${encodeURIComponent(cfg.site)}`, { method: 'GET', headers: bearer(cfg) });
175
+ if (orphans.ok) {
176
+ const list = Array.isArray(orphans.data)
177
+ ? orphans.data
178
+ : (orphans.data?.orphans ?? []);
179
+ if (Array.isArray(list) && list.length > 0)
180
+ return true;
181
+ }
182
+ return false;
183
+ },
184
+ };
185
+ }
@@ -0,0 +1,299 @@
1
+ /**
2
+ * 司令塔の「考える部分」だけを集めた1本(試作完走 便⑥・えふさん確定 2026-09-05)。
3
+ *
4
+ * 【なぜ切り出すか】
5
+ * 司令塔は常駐で、外(中央の口・会議・管制)と何度も話す。そのまま書くと
6
+ * 「本当に確かめたいこと(便の並べ方・積み直しの回数・URL の取り出し)」を試すたびに
7
+ * 通信が要る。★判断だけをここへ集めて、外との話は src/build-central.ts へ寄せる。
8
+ *
9
+ * 【★写しを作らない】
10
+ * 進み具合の JSON の【項目名】だけを定数に持つ(中央 devlog-tracker の
11
+ * src/lib/build-progress.ts が形の正本)。★型を写経しない。写経すると、中央が項目を
12
+ * 足したときに司令塔だけ古い形のまま書き続ける。
13
+ */
14
+ /** 司令塔だけが書く入れ物(★試作のアプリからは触れない)。 */
15
+ export const BUILD_COLLECTION = '_build';
16
+ /** 行の種類(★中央・画面・司令塔で同じ2語を使う)。 */
17
+ export const BUILD_PLAN_KIND = 'plan';
18
+ export const BUILD_PROGRESS_KIND = 'progress';
19
+ /** 進み方の6段階(★並びもここが正本。中央の画面と同じ綴り)。 */
20
+ export const BUILD_STAGES = ['design', 'data', 'screens', 'features', 'testing', 'release'];
21
+ /** 最後の2便は必ずこの順(★受け入れテスト → 配備)。 */
22
+ export const FINAL_STAGES = ['testing', 'release'];
23
+ /** 便の本数の上限(★1便30〜60分の粒。長すぎる計画は人が待てない)。 */
24
+ export const MAX_DELIVERIES = 20;
25
+ /** 題名の長さの上限(★お客さまが読む1行)。 */
26
+ export const MAX_TITLE_LENGTH = 60;
27
+ /** 同じ便を【積み直せる】回数(★最初の1回は含まない。これを超えたら人へ回す)。 */
28
+ export const MAX_RETRIES = 3;
29
+ /** まだ何も進んでいない進み具合。 */
30
+ export function emptyProgress() {
31
+ return {
32
+ current: 0,
33
+ state: 'working',
34
+ done: [],
35
+ doneCount: 0,
36
+ results: [],
37
+ url: '',
38
+ passed: 0,
39
+ blockedReason: '',
40
+ };
41
+ }
42
+ function isStage(v) {
43
+ return typeof v === 'string' && BUILD_STAGES.includes(v);
44
+ }
45
+ function text(v) {
46
+ return typeof v === 'string' ? v.trim() : '';
47
+ }
48
+ /** 1行に整える(改行は空白へ・長すぎる題名は切る)。 */
49
+ export function oneLine(v, max = MAX_TITLE_LENGTH) {
50
+ const s = text(v).replace(/\s+/g, ' ');
51
+ return s.length > max ? `${s.slice(0, max - 1)}…` : s;
52
+ }
53
+ // ───────────────────────────────────────────────────────────────────────────
54
+ // 設計書(19項目)の読み方
55
+ // ───────────────────────────────────────────────────────────────────────────
56
+ /**
57
+ * 何が来ても箇条書きにする(★中央の product-spec.ts と同じ考え方)。
58
+ * 設計書は AI が書くので、同じ項目が文字列でも配列でも塊でも来る。
59
+ * ★読めない物は捨てる。落とすより、1件抜けて見える方がよい。
60
+ */
61
+ export function asList(v) {
62
+ if (v === null || v === undefined)
63
+ return [];
64
+ if (Array.isArray(v))
65
+ return v.map((x) => asOneText(x)).filter(Boolean);
66
+ if (typeof v === 'string') {
67
+ return v
68
+ .split('\n')
69
+ .map((l) => l.replace(/^\s*[-*•・]\s*/, '').trim())
70
+ .filter(Boolean);
71
+ }
72
+ if (typeof v === 'object') {
73
+ return Object.values(v)
74
+ .map((x) => asOneText(x))
75
+ .filter(Boolean);
76
+ }
77
+ const one = asOneText(v);
78
+ return one ? [one] : [];
79
+ }
80
+ /** 塊から人が読む1行を取り出す(名前 → 題名 → 説明の順に探す)。 */
81
+ export function asOneText(v) {
82
+ if (typeof v === 'string')
83
+ return v.trim();
84
+ if (typeof v === 'number' || typeof v === 'boolean')
85
+ return String(v);
86
+ if (Array.isArray(v))
87
+ return v.map(asOneText).filter(Boolean).join('/');
88
+ if (v && typeof v === 'object') {
89
+ const o = v;
90
+ for (const k of ['name', 'title', 'text', 'label', 'description', 'summary']) {
91
+ const hit = asOneText(o[k]);
92
+ if (hit)
93
+ return hit;
94
+ }
95
+ return '';
96
+ }
97
+ return '';
98
+ }
99
+ // ───────────────────────────────────────────────────────────────────────────
100
+ // 計画を作る
101
+ // ───────────────────────────────────────────────────────────────────────────
102
+ /**
103
+ * 会議が返した文から便の一覧を読む。
104
+ *
105
+ * ★```json の囲みが付いていても読む(AI はよく付ける)。
106
+ * ★{deliveries:[…]} でも、いきなり […] でも読む。
107
+ * ★1件も読めなければ空を返す=呼ぶ側が「機械で組む」へ倒す(会議に頼りきらない)。
108
+ */
109
+ export function parsePlanJson(raw) {
110
+ const body = stripFence(raw);
111
+ const parsed = firstJson(body);
112
+ if (parsed === null)
113
+ return [];
114
+ const list = Array.isArray(parsed)
115
+ ? parsed
116
+ : Array.isArray(parsed.deliveries)
117
+ ? (parsed.deliveries)
118
+ : [];
119
+ const out = [];
120
+ for (const item of list) {
121
+ const d = (item ?? {});
122
+ const title = oneLine(d.title ?? d.name);
123
+ if (!title)
124
+ continue;
125
+ // ★段階が読めない便は落とす(画面の6段階のどこにも置けないため)。
126
+ if (!isStage(d.stage))
127
+ continue;
128
+ out.push({ no: out.length + 1, title, stage: d.stage });
129
+ }
130
+ return out;
131
+ }
132
+ /** ``` の囲みを外す。 */
133
+ function stripFence(raw) {
134
+ const m = /```(?:json)?\s*([\s\S]*?)```/i.exec(raw);
135
+ return (m ? m[1] : raw).trim();
136
+ }
137
+ /** 文の中の最初の JSON(塊か配列)を読む。読めなければ null。 */
138
+ function firstJson(body) {
139
+ const starts = [body.indexOf('{'), body.indexOf('[')].filter((i) => i >= 0);
140
+ if (starts.length === 0)
141
+ return null;
142
+ const from = Math.min(...starts);
143
+ const opener = body[from];
144
+ const closer = opener === '{' ? '}' : ']';
145
+ const to = body.lastIndexOf(closer);
146
+ if (to <= from)
147
+ return null;
148
+ try {
149
+ return JSON.parse(body.slice(from, to + 1));
150
+ }
151
+ catch {
152
+ return null;
153
+ }
154
+ }
155
+ /**
156
+ * ★会議が返さなかったときに、設計書から機械的に組む計画。
157
+ *
158
+ * 【なぜ要るか】会議は外のサービス。落ちることも、鍵が未設定のこともある。
159
+ * ★そこで司令塔ごと止まると「試作する」を押した人が何も知らされずに待つことになる。
160
+ * 会議は良い題名を付けるためのもので、無くても作る順番は設計書から決められる。
161
+ *
162
+ * 並べ方(★画面の6段階の順そのまま):
163
+ * design 1本 → data 1本 → screens(画面ごと)→ features(最初に作る機能ごと)
164
+ * → testing 1本 → release 1本
165
+ */
166
+ export function fallbackPlan(spec) {
167
+ const out = [];
168
+ out.push({ title: '作るものを決めて、土台を用意します', stage: 'design' });
169
+ const entities = asList(spec.data_model).length > 0 ? asList(spec.data_model) : asList(spec.entities);
170
+ out.push({
171
+ title: entities.length > 0 ? `データの置き場を作ります(${oneLine(entities[0], 20)} ほか)` : 'データの置き場を作ります',
172
+ stage: 'data',
173
+ });
174
+ for (const screen of asList(spec.screens)) {
175
+ out.push({ title: `${oneLine(screen, 40)} の画面を作ります`, stage: 'screens' });
176
+ }
177
+ const features = asList(spec.prototype_scope).length > 0 ? asList(spec.prototype_scope) : asList(spec.user_flows);
178
+ for (const f of features) {
179
+ out.push({ title: `${oneLine(f, 40)} を動くようにします`, stage: 'features' });
180
+ }
181
+ return normalizePlan(out);
182
+ }
183
+ /**
184
+ * 便の一覧を整える(★計画を中央へ書く前に必ず通す)。
185
+ *
186
+ * ・題名が空/段階が読めない便は落とす
187
+ * ・多すぎる便は切る(★最後の2便のぶんは必ず空けておく)
188
+ * ・★最後の2便を必ず testing → release にする(無ければ足す・在れば末尾へ寄せる)
189
+ * ・番号を1から振り直す(★会議が飛び番を書いても、画面は順番どおりに見える)
190
+ */
191
+ export function normalizePlan(items) {
192
+ // 最後の2段階は別枠。本体からは外して数える。
193
+ const body = items.filter((d) => !FINAL_STAGES.includes(d.stage) && oneLine(d.title) !== '');
194
+ const finals = FINAL_STAGES.map((stage) => {
195
+ const found = items.find((d) => d.stage === stage && oneLine(d.title) !== '');
196
+ return {
197
+ title: oneLine(found?.title) || defaultFinalTitle(stage),
198
+ stage,
199
+ };
200
+ });
201
+ const room = Math.max(0, MAX_DELIVERIES - finals.length);
202
+ const kept = body.slice(0, room);
203
+ return [...kept, ...finals].map((d, i) => ({
204
+ no: i + 1,
205
+ title: oneLine(d.title),
206
+ stage: d.stage,
207
+ }));
208
+ }
209
+ function defaultFinalTitle(stage) {
210
+ return stage === 'testing' ? '出来上がりを確かめます' : '触れる場所に公開します';
211
+ }
212
+ // ───────────────────────────────────────────────────────────────────────────
213
+ // 進み具合を進める
214
+ // ───────────────────────────────────────────────────────────────────────────
215
+ /** 次に積む便(★終わっていない一番小さい番号。全部終わっていれば null)。 */
216
+ export function nextDelivery(deliveries, progress) {
217
+ const done = new Set(progress.done);
218
+ return deliveries.find((d) => !done.has(d.no)) ?? null;
219
+ }
220
+ /**
221
+ * 結末を見て次の一手を決める。
222
+ *
223
+ * ★pushed / no_change は成功。それ以外(blocked・failed・時間切れ・申告なし)は積み直し。
224
+ * ★「申告なし(null)」を成功にしない。職人が落ちて何も言い残せなかった形なので、
225
+ * もう一度やらせるのが正しい(積み直しは最大 MAX_RETRIES 回で必ず止まる)。
226
+ *
227
+ * @param attempt これまでに【積んだ】回数(最初の1回=1)。
228
+ * attempt=1〜3 の失敗までは積み直し(=積み直しは3回)、4回目の失敗で人へ回す。
229
+ */
230
+ export function decideStep(outcome, attempt) {
231
+ if (outcome === 'pushed' || outcome === 'no_change')
232
+ return 'success';
233
+ return attempt <= MAX_RETRIES ? 'retry' : 'give-up';
234
+ }
235
+ /**
236
+ * 公開先の URL を報告から取り出す(★release 便の報告の1行)。
237
+ *
238
+ * ★http(s) だけ。★複数あれば最後の1つ(やり直した便では後の行が新しい)。
239
+ */
240
+ export function extractUrl(report) {
241
+ const re = /(?:^|\n)\s*URL\s*[::]\s*(https?:\/\/\S+)/gi;
242
+ let url = '';
243
+ for (const m of report.matchAll(re))
244
+ url = m[1];
245
+ return url.replace(/[),.。、]+$/, '');
246
+ }
247
+ /** 受け入れテストで通った数(★testing 便の報告の1行 `ACCEPTANCE: 3/5`)。 */
248
+ export function extractAcceptance(report) {
249
+ const re = /(?:^|\n)\s*ACCEPTANCE\s*[::]\s*(\d+)\s*[//]\s*(\d+)/gi;
250
+ let hit = null;
251
+ for (const m of report.matchAll(re))
252
+ hit = m;
253
+ if (!hit)
254
+ return null;
255
+ const passed = Number(hit[1]);
256
+ const total = Number(hit[2]);
257
+ if (!Number.isFinite(passed) || !Number.isFinite(total))
258
+ return null;
259
+ return { passed: Math.min(passed, total), total };
260
+ }
261
+ /**
262
+ * 便1本が終わったときの進み具合(★新しい値を返す=元は変えない)。
263
+ *
264
+ * ★url と passed は「読み取れたときだけ」上書きする。読み取れなかった便で
265
+ * 前に取れていた値を消さない(消すと画面の「触ってみる」が突然消える)。
266
+ */
267
+ export function applyResult(progress, delivery, outcome, report) {
268
+ const done = progress.done.includes(delivery.no) ? progress.done : [...progress.done, delivery.no];
269
+ const results = [
270
+ ...progress.results.filter((r) => r.no !== delivery.no),
271
+ { no: delivery.no, outcome, report: oneLineReport(report) },
272
+ ].sort((a, b) => a.no - b.no);
273
+ const url = delivery.stage === 'release' ? extractUrl(report) || progress.url : progress.url;
274
+ const acceptance = delivery.stage === 'testing' ? extractAcceptance(report) : null;
275
+ return {
276
+ ...progress,
277
+ done,
278
+ doneCount: done.length,
279
+ results,
280
+ url,
281
+ passed: acceptance ? acceptance.passed : progress.passed,
282
+ };
283
+ }
284
+ /** 報告は畳みの中に出るだけなので、長すぎるものは切る(★中央は2万字で切る)。 */
285
+ export function oneLineReport(report, max = 4000) {
286
+ const s = report.trim();
287
+ return s.length > max ? `${s.slice(0, max)}…` : s;
288
+ }
289
+ /**
290
+ * 人へ回すときの1行(★専門用語を書かない)。
291
+ *
292
+ * ★「何が起きたか」ではなく「何をしてほしいか」を書く。読むのは非エンジニア。
293
+ */
294
+ export function blockedReasonFor(delivery, report) {
295
+ if (delivery.stage === 'release' && /vercel|公開先|プロジェクトが見つかりません/i.test(report)) {
296
+ return '公開先の準備が要ります。担当者が確認します。';
297
+ }
298
+ return `「${delivery.title}」の途中でうまく進められませんでした。担当者が確認します。`;
299
+ }