@tyhld/conductor 0.10.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,463 @@
1
+ /**
2
+ * 司令塔の常駐(試作完走 便⑥・えふさん確定 2026-09-05)。
3
+ *
4
+ * 【やること】「この内容で試作する →」を押されたら、人を止めずに最後まで運ぶ。
5
+ * ① 探す … 承認済みでまだ始まっていない設計書を60秒ごとに見る
6
+ * ② 計画 … 会議に便の一覧を起草させる(返らなければ設計書から機械的に組む)
7
+ * ③ 積む … 便を【1本ずつ】管制へ積む(★同時1本。前が終わるまで次を積まない)
8
+ * ④ 待つ … 終わったか・詰まったかを管制から読む
9
+ * ⑤ 書く … 進み具合を試作データの口へ書く(★お客さまの画面はそれを読むだけ)
10
+ * ⑥ 直す … 詰まったら同じ便を積み直す(最大3回)。超えたら人へ回して止まる
11
+ * ⑦ 締める … 公開の便の報告から URL を、受け入れテストの便から通った数を取り出す
12
+ *
13
+ * 【★同時1本】
14
+ * 1つの試作が終わるまで次の試作を取らない。★複数を並べると、職人(1現場に1人)の
15
+ * 取り合いになり、どちらも進まないのに画面上は2つとも「作っています」になる。
16
+ *
17
+ * 【★落ちても続きから】
18
+ * 何本目まで進んだかは【中央に書いてある物】が正(口②)。この常駐が手元に持つのは
19
+ * 「いまどの試作の何本目を待っているか」だけ(★鍵も本文も持たない小さな覚え書き)。
20
+ * 覚え書きが消えても、積みっぱなしの便を二重に積まないよう、積む前に必ず管制へ確かめる。
21
+ *
22
+ * 判断(便の並べ方・積み直し・URL の取り出し)は src/build-plan.ts、
23
+ * 外との話は src/build-central.ts と src/build-roundtable.ts にある。
24
+ */
25
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
26
+ import os from 'node:os';
27
+ import path from 'node:path';
28
+ import { BUILD_PLAN_KIND, BUILD_PROGRESS_KIND, BUILD_STAGES, applyResult, blockedReasonFor, decideStep, emptyProgress, fallbackPlan, nextDelivery, normalizePlan, parsePlanJson, } from './build-plan.js';
29
+ import { buildCraftsmanPrompt } from './build-prompt.js';
30
+ import { draftPlanByRoundtable } from './build-roundtable.js';
31
+ /** 既定の現場名(★試作の現場は1つ。増やすときは env で渡す)。 */
32
+ export const DEFAULT_BUILD_SITE = 'ai-kana-proto';
33
+ /** 口①を見にいく間隔・秒(★60秒。耳の合図が来れば待たずに1周する)。 */
34
+ export const DEFAULT_BUILD_INTERVAL_SEC = 60;
35
+ /** 合図の受け口の名前(★現場名とぶつからないよう頭に `_` を付ける)。 */
36
+ export const BUILD_NUDGE_NAME = '_build-runner';
37
+ // ───────────────────────────────────────────────────────────────────────────
38
+ // 設定(★足りなければ、何が足りないかを言って止まる)
39
+ // ───────────────────────────────────────────────────────────────────────────
40
+ /** 司令塔が要る環境変数と、その説明(★人が読む)。 */
41
+ export const REQUIRED_ENV = [
42
+ { name: 'CONDUCTOR_URL', note: '管制のアドレス' },
43
+ { name: 'CONDUCTOR_TOKEN', note: '管制の合言葉' },
44
+ { name: 'PROTO_API_BASE', note: '試作の口のアドレス(例 https://ai-kana.ty-hld.com)' },
45
+ { name: 'CONDUCTOR_PROTO_APP_KEY', note: '試作の口の合言葉(★司令塔専用)' },
46
+ { name: 'ROUNDTABLE_BASE_URL', note: '会議のアドレス' },
47
+ { name: 'ROUNDTABLE_APP_KEY', note: '会議の合言葉' },
48
+ ];
49
+ /**
50
+ * 環境変数から設定を組み立てる。
51
+ *
52
+ * ★足りない物は【全部まとめて】言う。1つずつ言うと、人は入れ直しを何度も繰り返す。
53
+ * ★値そのものは絶対に出さない(名前だけ)。
54
+ */
55
+ export function loadBuildRunnerConfig(env = process.env) {
56
+ const missing = REQUIRED_ENV.filter(({ name }) => (env[name] ?? '').trim() === '');
57
+ if (missing.length > 0) {
58
+ throw new Error('司令塔を動かすには、次の設定が要ります(~/.conductor.env に人が入れてください):\n' +
59
+ missing.map((m) => ` ・${m.name} … ${m.note}`).join('\n'));
60
+ }
61
+ const site = (env.CONDUCTOR_BUILD_SITE ?? '').trim() || DEFAULT_BUILD_SITE;
62
+ const home = env.HOME ?? os.homedir();
63
+ let intervalSec = DEFAULT_BUILD_INTERVAL_SEC;
64
+ const raw = (env.CONDUCTOR_BUILD_INTERVAL_SEC ?? '').trim();
65
+ if (raw !== '') {
66
+ const parsed = Number(raw);
67
+ if (!Number.isFinite(parsed) || parsed <= 0) {
68
+ throw new Error(`CONDUCTOR_BUILD_INTERVAL_SEC は正の数で指定してください(指定値: "${raw}")`);
69
+ }
70
+ intervalSec = parsed;
71
+ }
72
+ return {
73
+ url: (env.CONDUCTOR_URL ?? '').trim().replace(/\/+$/, ''),
74
+ token: (env.CONDUCTOR_TOKEN ?? '').trim(),
75
+ machine: (env.CONDUCTOR_MACHINE ?? '').trim() || os.hostname(),
76
+ site,
77
+ projectDir: (env.CONDUCTOR_BUILD_DIR ?? '').trim() || path.join(home, 'projects', site),
78
+ protoApiBase: (env.PROTO_API_BASE ?? '').trim().replace(/\/+$/, ''),
79
+ protoAppKey: (env.CONDUCTOR_PROTO_APP_KEY ?? '').trim(),
80
+ roundtableBaseUrl: (env.ROUNDTABLE_BASE_URL ?? '').trim().replace(/\/+$/, ''),
81
+ roundtableAppKey: (env.ROUNDTABLE_APP_KEY ?? '').trim(),
82
+ intervalSec,
83
+ };
84
+ }
85
+ /**
86
+ * 「積んだかどうか分からない」状態で待つ時間(ms)。
87
+ *
88
+ * ★管制が配達中の便を孤児として見せ始めるのは3分後(中央の復元しきい値)。
89
+ * それより長く待てば、便が本当に積まれていたなら必ず気づける。
90
+ */
91
+ export const PENDING_GRACE_MS = 5 * 60 * 1000;
92
+ /** 覚え書きの置き場(★再起動で消えない場所。合図の置き場とは別)。 */
93
+ export function resolveMemoPath(env = process.env) {
94
+ const explicit = (env.CONDUCTOR_BUILD_STATE ?? '').trim();
95
+ if (explicit !== '')
96
+ return explicit;
97
+ const state = (env.XDG_STATE_HOME ?? '').trim();
98
+ const home = env.HOME ?? os.homedir();
99
+ const dir = state !== '' ? path.join(state, 'conductor') : path.join(home, '.local', 'state', 'conductor');
100
+ return path.join(dir, 'build-runner.json');
101
+ }
102
+ /** ファイルで覚える(★読めない・書けないときは「覚えていない」として振る舞う)。 */
103
+ export function createMemoStore(file = resolveMemoPath()) {
104
+ return {
105
+ read() {
106
+ try {
107
+ if (!existsSync(file))
108
+ return null;
109
+ const parsed = JSON.parse(readFileSync(file, 'utf8'));
110
+ if (typeof parsed?.projectId !== 'string' || parsed.projectId === '')
111
+ return null;
112
+ return {
113
+ projectId: parsed.projectId,
114
+ tenantId: typeof parsed.tenantId === 'string' ? parsed.tenantId : '',
115
+ specId: typeof parsed.specId === 'string' ? parsed.specId : '',
116
+ spec: parsed.spec && typeof parsed.spec === 'object' ? parsed.spec : {},
117
+ acceptanceCriteria: Array.isArray(parsed.acceptanceCriteria)
118
+ ? parsed.acceptanceCriteria.filter((x) => typeof x === 'string')
119
+ : [],
120
+ no: typeof parsed.no === 'number' ? parsed.no : 0,
121
+ attempt: typeof parsed.attempt === 'number' ? parsed.attempt : 0,
122
+ commandId: typeof parsed.commandId === 'string' ? parsed.commandId : '',
123
+ pendingSince: typeof parsed.pendingSince === 'number' ? parsed.pendingSince : 0,
124
+ };
125
+ }
126
+ catch {
127
+ return null;
128
+ }
129
+ },
130
+ write(memo) {
131
+ try {
132
+ mkdirSync(path.dirname(file), { recursive: true });
133
+ writeFileSync(file, `${JSON.stringify(memo, null, 2)}\n`, { mode: 0o600 });
134
+ }
135
+ catch {
136
+ /* 覚えられなくても止まらない。次の回に中央から拾い直す。 */
137
+ }
138
+ },
139
+ clear() {
140
+ try {
141
+ rmSync(file, { force: true });
142
+ }
143
+ catch {
144
+ /* 残っても次の回に中央が「終わっている」と言う */
145
+ }
146
+ },
147
+ };
148
+ }
149
+ // ───────────────────────────────────────────────────────────────────────────
150
+ // 人が読む記録(★型番・鍵を出さない)
151
+ // ───────────────────────────────────────────────────────────────────────────
152
+ /**
153
+ * 記録の1行から、人に意味の無い文字列を伏せる。
154
+ *
155
+ * ★型番(projectId などの UUID)と、鍵に見える長い文字列を出さない。
156
+ * ★この記録は journalctl に残り、人がそのままサポートへ送る。そこに鍵が写ってはいけない。
157
+ */
158
+ export function redact(line) {
159
+ return line
160
+ .replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, '(型番)')
161
+ .replace(/\b[A-Za-z0-9_-]{32,}\b/g, '(伏せ字)');
162
+ }
163
+ /** 画面(journalctl)へ1行。 */
164
+ export function consoleJournal(line) {
165
+ console.log(`[build] ${new Date().toLocaleTimeString()} ${redact(line)}`);
166
+ }
167
+ // ───────────────────────────────────────────────────────────────────────────
168
+ // 中央から読んだ物の受け取り方(★形が違っても落とさない)
169
+ // ───────────────────────────────────────────────────────────────────────────
170
+ function isStage(v) {
171
+ return typeof v === 'string' && BUILD_STAGES.includes(v);
172
+ }
173
+ /** 口②が返した計画を読む。 */
174
+ export function readPlanFromCentral(raw) {
175
+ const list = raw?.deliveries;
176
+ if (!Array.isArray(list))
177
+ return [];
178
+ const out = [];
179
+ for (const item of list) {
180
+ const d = (item ?? {});
181
+ const no = typeof d.no === 'number' && d.no > 0 ? Math.floor(d.no) : 0;
182
+ if (no === 0 || !isStage(d.stage))
183
+ continue;
184
+ out.push({ no, title: typeof d.title === 'string' ? d.title : '', stage: d.stage });
185
+ }
186
+ return out;
187
+ }
188
+ /** 口②が返した進み具合を読む。 */
189
+ export function readProgressFromCentral(raw) {
190
+ const o = (raw ?? {});
191
+ const base = emptyProgress();
192
+ const num = (v) => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? Math.floor(v) : 0);
193
+ const state = o.state;
194
+ return {
195
+ current: num(o.current),
196
+ state: state === 'done' || state === 'blocked' || state === 'waiting' ? state : 'working',
197
+ done: Array.isArray(o.done) ? o.done.map(num).filter((n) => n > 0) : base.done,
198
+ doneCount: num(o.doneCount) || (Array.isArray(o.done) ? o.done.length : 0),
199
+ results: Array.isArray(o.results)
200
+ ? o.results
201
+ .map((r) => (r ?? {}))
202
+ .filter((r) => num(r.no) > 0)
203
+ .map((r) => ({
204
+ no: num(r.no),
205
+ outcome: typeof r.outcome === 'string' ? r.outcome : '',
206
+ report: typeof r.report === 'string' ? r.report : '',
207
+ }))
208
+ : base.results,
209
+ url: typeof o.url === 'string' ? o.url : '',
210
+ passed: num(o.passed),
211
+ blockedReason: typeof o.blockedReason === 'string' ? o.blockedReason : '',
212
+ };
213
+ }
214
+ /** 「もう職人の手が離れた」状態(★これ以上待っても変わらない)。 */
215
+ const TERMINAL_FAILURES = new Set(['timeout', 'failed', 'stalled', 'dismissed', 'cancelled']);
216
+ export function createBuildRunner(args) {
217
+ const { cfg, ports, memo } = args;
218
+ const journal = args.journal ?? consoleJournal;
219
+ const now = args.now ?? (() => Date.now());
220
+ const draft = args.draft ??
221
+ ((item) => draftPlanByRoundtable({ baseUrl: cfg.roundtableBaseUrl, appKey: cfg.roundtableAppKey }, {
222
+ tenantId: item.tenantId,
223
+ title: '試作の便の一覧',
224
+ spec: item.spec,
225
+ acceptanceCriteria: item.acceptanceCriteria,
226
+ }));
227
+ /** 進み具合を中央へ書く(★書けなければ次の回にやり直す)。 */
228
+ async function writeProgress(projectId, progress) {
229
+ const ok = await ports.writeBuildRecord(projectId, { kind: BUILD_PROGRESS_KIND, ...progress });
230
+ if (!ok)
231
+ journal('進み具合を書けませんでした。次の確認でやり直します');
232
+ }
233
+ /**
234
+ * 便の一覧を作る。
235
+ * ★会議が返さなければ設計書から機械的に組む(会議が落ちていても人を待たせない)。
236
+ */
237
+ async function makePlan(item) {
238
+ const res = await draft(item);
239
+ if (res.ok) {
240
+ const parsed = parsePlanJson(res.content);
241
+ if (parsed.length > 0)
242
+ return normalizePlan(parsed);
243
+ journal('会議の答えを便の一覧として読めませんでした。設計書から組み立てます');
244
+ }
245
+ else {
246
+ journal(`会議で決められませんでした(${res.message})。設計書から組み立てます`);
247
+ }
248
+ return fallbackPlan(item.spec);
249
+ }
250
+ /** 積んだ便が終わったかを読む(★まだなら null)。 */
251
+ async function collectOutcome(commandId) {
252
+ const dones = await ports.listDone();
253
+ const hit = dones.find((d) => d.id === commandId);
254
+ if (hit) {
255
+ // ★読んだら印を付ける(同じ便を二度数えない)。
256
+ await ports.markOrchestrated(commandId);
257
+ return { outcome: hit.outcome, report: hit.report };
258
+ }
259
+ const status = await ports.commandStatus(commandId);
260
+ if (status === null)
261
+ return null; // 中央に聞けなかった=未知。次の回にやり直す。
262
+ if (status === 'done') {
263
+ // ★終わってはいるが、完了の一覧から読めなかった(既に印が付いている等)。
264
+ // ここで積み直すと、もう入っている変更をもう一度やらせることになる。
265
+ // ★「終わった。ただし報告は受け取れなかった」として先へ進む。
266
+ journal('前の便は終わっていましたが、報告を受け取れませんでした');
267
+ return { outcome: 'pushed', report: '' };
268
+ }
269
+ if (TERMINAL_FAILURES.has(status)) {
270
+ journal('前の便は途中で終わってしまいました');
271
+ return { outcome: 'blocked', report: '' };
272
+ }
273
+ return null; // queued / sent / perm_wait … まだ進んでいる。
274
+ }
275
+ /** 便を1本積む。★積めたら覚え書きを更新する。 */
276
+ async function enqueue(m, deliveries, target, spec, progress, attempt, retryOf) {
277
+ // ★二重に積まない。覚え書きが消えた後の再起動でも、ここで必ず気づく。
278
+ if (await ports.hasInFlight()) {
279
+ journal('前の便がまだ動いています。終わるまで待ちます');
280
+ return false;
281
+ }
282
+ const body = buildCraftsmanPrompt({
283
+ site: cfg.site,
284
+ projectDir: cfg.projectDir,
285
+ delivery: target,
286
+ total: deliveries.length,
287
+ spec,
288
+ previous: progress.results,
289
+ deliveries,
290
+ protoApiBase: cfg.protoApiBase,
291
+ ...(retryOf !== '' ? { retryOf } : {}),
292
+ });
293
+ // ★積む【前】に「いま積もうとしている」を残す。積んだ直後に落ちても、
294
+ // 次の起動で「積んだかもしれない」と分かる=いきなり二度目を積まない。
295
+ memo.write({ ...m, no: target.no, attempt, commandId: '', pendingSince: now() });
296
+ const id = await ports.enqueue(body);
297
+ if (id === null) {
298
+ journal('便を積めませんでした。次の確認でやり直します');
299
+ // ★積めなかったことは確かなので、印は消してよい(次の回にすぐやり直す)。
300
+ memo.write({ ...m, no: target.no, attempt: attempt - 1, commandId: '', pendingSince: 0 });
301
+ return false;
302
+ }
303
+ memo.write({ ...m, no: target.no, attempt, commandId: id, pendingSince: 0 });
304
+ journal(attempt > 1
305
+ ? `${target.no}本目「${target.title}」をもう一度お願いしました(${attempt}回目)`
306
+ : `${target.no}本目「${target.title}」をお願いしました`);
307
+ return true;
308
+ }
309
+ /** 進行中の試作を1歩進める。 */
310
+ async function advance(m) {
311
+ const build = await ports.getBuild(m.projectId);
312
+ if (build === null) {
313
+ // ★口②が 404=計画がまだ書けていない。覚え書きだけ残っている状態。
314
+ journal('進み具合を読めませんでした。次の確認でやり直します');
315
+ return;
316
+ }
317
+ const deliveries = readPlanFromCentral(build.plan);
318
+ let progress = readProgressFromCentral(build.progress);
319
+ if (deliveries.length === 0) {
320
+ journal('便の一覧が読めませんでした。担当者が確認します');
321
+ progress.state = 'blocked';
322
+ progress.blockedReason = 'うまく段取りを作れませんでした。担当者が確認します。';
323
+ await writeProgress(m.projectId, progress);
324
+ memo.clear();
325
+ return;
326
+ }
327
+ if (progress.state === 'done' || progress.state === 'blocked') {
328
+ memo.clear(); // ★もう終わっている。次の試作へ。
329
+ return;
330
+ }
331
+ // ── 積んだかどうか分からない便を片付ける(★積む直前に落ちた形)──
332
+ // 管制に在るかもしれないので、勝手に積み直さない。
333
+ if (m.commandId === '' && m.no > 0 && m.pendingSince > 0) {
334
+ const orphanDone = (await ports.listDone())[0];
335
+ if (orphanDone !== undefined) {
336
+ // ★この現場へ便を積むのは司令塔だけ。終わっている便=いま待っていた便。
337
+ journal('積んだ便が終わっていました。結末を引き取ります');
338
+ memo.write({ ...m, commandId: orphanDone.id, pendingSince: 0 });
339
+ m = { ...m, commandId: orphanDone.id, pendingSince: 0 };
340
+ }
341
+ else if (await ports.hasInFlight()) {
342
+ journal('積んだ便が動いていました。終わるまで待ちます');
343
+ return;
344
+ }
345
+ else if (now() - m.pendingSince < PENDING_GRACE_MS) {
346
+ journal('前の便が積まれたかどうかを確かめています');
347
+ return;
348
+ }
349
+ else {
350
+ // ★十分待っても管制に現れない=積まれていなかった。同じ便をもう一度積む。
351
+ m = { ...m, pendingSince: 0, attempt: Math.max(0, m.attempt - 1) };
352
+ memo.write(m);
353
+ }
354
+ }
355
+ // ── 積んだ便の結末を受け取る ──
356
+ if (m.commandId !== '') {
357
+ const step = await collectOutcome(m.commandId);
358
+ if (step === null)
359
+ return; // まだ動いている。
360
+ const delivery = deliveries.find((d) => d.no === m.no);
361
+ if (delivery === undefined) {
362
+ // 計画が書き換わった等。覚え書きを捨てて、次の便から組み直す。
363
+ memo.write({ ...m, no: 0, attempt: 0, commandId: '', pendingSince: 0 });
364
+ return;
365
+ }
366
+ const decision = decideStep(step.outcome, m.attempt);
367
+ if (decision === 'success') {
368
+ progress = applyResult(progress, delivery, step.outcome ?? 'pushed', step.report);
369
+ const next = nextDelivery(deliveries, progress);
370
+ progress.current = next?.no ?? delivery.no;
371
+ progress.state = next === null ? 'done' : 'working';
372
+ await writeProgress(m.projectId, progress);
373
+ journal(`${delivery.no}本目「${delivery.title}」が終わりました(${progress.doneCount}/${deliveries.length})`);
374
+ if (delivery.stage === 'release' && progress.url !== '')
375
+ journal('触れる場所が用意できました');
376
+ if (next === null) {
377
+ journal('この試作はぜんぶ終わりました');
378
+ memo.clear();
379
+ return;
380
+ }
381
+ memo.write({ ...m, no: 0, attempt: 0, commandId: '', pendingSince: 0 });
382
+ m = { ...m, no: 0, attempt: 0, commandId: '', pendingSince: 0 };
383
+ }
384
+ else if (decision === 'retry') {
385
+ const attempt = m.attempt + 1;
386
+ progress.current = delivery.no;
387
+ progress.state = 'working';
388
+ await writeProgress(m.projectId, progress);
389
+ await enqueue(m, deliveries, delivery, m.spec, progress, attempt, step.report);
390
+ return;
391
+ }
392
+ else {
393
+ progress.state = 'blocked';
394
+ progress.blockedReason = blockedReasonFor(delivery, step.report);
395
+ progress.current = delivery.no;
396
+ await writeProgress(m.projectId, progress);
397
+ journal(`${delivery.no}本目を${m.attempt}回試しても進めませんでした。担当者へお知らせします`);
398
+ memo.clear();
399
+ return;
400
+ }
401
+ }
402
+ // ── 次の便を積む ──
403
+ const target = nextDelivery(deliveries, progress);
404
+ if (target === null) {
405
+ progress.state = 'done';
406
+ await writeProgress(m.projectId, progress);
407
+ journal('この試作はぜんぶ終わりました');
408
+ memo.clear();
409
+ return;
410
+ }
411
+ progress.current = target.no;
412
+ progress.state = 'working';
413
+ await writeProgress(m.projectId, progress);
414
+ // ★同じ便へ戻ってきたときは、試した回数を引き継ぐ(積み直しの数え直しをさせない)。
415
+ const attempt = target.no === m.no ? m.attempt + 1 : 1;
416
+ await enqueue(m, deliveries, target, m.spec, progress, attempt, '');
417
+ }
418
+ /** まだ始まっていない試作を1つ拾って始める。 */
419
+ async function start() {
420
+ const items = await ports.listSpecs(1);
421
+ if (items.length === 0)
422
+ return;
423
+ const item = items[0];
424
+ journal('新しい試作の設計書を受け取りました。段取りを組みます');
425
+ const deliveries = await makePlan(item);
426
+ // ★計画を書いた時点で、この試作は口①から外れる(=二度始めない)。
427
+ const ok = await ports.writeBuildRecord(item.projectId, {
428
+ kind: BUILD_PLAN_KIND,
429
+ deliveries,
430
+ });
431
+ if (!ok) {
432
+ journal('段取りを書けませんでした。次の確認でやり直します');
433
+ return;
434
+ }
435
+ const progress = { ...emptyProgress(), current: deliveries[0]?.no ?? 0 };
436
+ await writeProgress(item.projectId, progress);
437
+ const m = {
438
+ projectId: item.projectId,
439
+ tenantId: item.tenantId,
440
+ specId: item.specId,
441
+ spec: item.spec,
442
+ acceptanceCriteria: item.acceptanceCriteria,
443
+ no: 0,
444
+ attempt: 0,
445
+ commandId: '',
446
+ pendingSince: 0,
447
+ };
448
+ memo.write(m);
449
+ journal(`${deliveries.length}本の作業に分けました。順番に進めます`);
450
+ // そのまま1本目を積む(★次の確認を60秒待たせない)。
451
+ await enqueue(m, deliveries, deliveries[0], item.spec, progress, 1, '');
452
+ }
453
+ return {
454
+ async tick() {
455
+ const m = memo.read();
456
+ if (m !== null) {
457
+ await advance(m);
458
+ return;
459
+ }
460
+ await start();
461
+ },
462
+ };
463
+ }
package/dist/cli.js CHANGED
@@ -19,6 +19,8 @@ import { registerNudgeTarget, unregisterNudgeTarget, watchNudges, } from './nudg
19
19
  import { startEar } from './ear.js';
20
20
  import { loadEarConfig } from './ear-routing.js';
21
21
  import { ensureEnvFilePermissions } from './env-file-perm.js';
22
+ import { createCentralPorts } from './build-central.js';
23
+ import { BUILD_NUDGE_NAME, REQUIRED_ENV, createBuildRunner, createMemoStore, loadBuildRunnerConfig, } from './build-runner.js';
22
24
  /** git コマンドを安全に実行し、失敗したら null を返す(git でない/未インストール等)。 */
23
25
  function git(args) {
24
26
  try {
@@ -278,6 +280,51 @@ function runEarCommand() {
278
280
  process.on('SIGINT', stop);
279
281
  process.on('SIGTERM', stop);
280
282
  }
283
+ /**
284
+ * `conductor build-runner`: この機に1本だけ動かす「司令塔」(試作完走 便⑥)。
285
+ * 承認された設計書を拾い、便に分け、1本ずつ管制へ積み、終わるまで見届ける。
286
+ * ★職人にも tmux にも触らない(積むのは管制まで。配るのは各現場の relay の仕事)。
287
+ */
288
+ function runBuildRunnerCommand() {
289
+ let cfg;
290
+ try {
291
+ cfg = loadBuildRunnerConfig();
292
+ }
293
+ catch (err) {
294
+ // ★足りない設定は「何が足りないか」をそのまま出して止まる(黙って動かないのが一番困る)。
295
+ fail(err instanceof Error ? err.message : String(err));
296
+ }
297
+ console.log(`[build] 開始: machine=${cfg.machine} 現場=${cfg.site} 間隔=${cfg.intervalSec}s`);
298
+ console.log('[build] 停止するには Ctrl+C を押してください。');
299
+ const runner = createBuildRunner({
300
+ cfg,
301
+ ports: createCentralPorts(cfg, { log: (m) => console.log(`[build] ${m}`) }),
302
+ memo: createMemoStore(),
303
+ });
304
+ // ★回し方は relay と同じ道具を使う(多重起動しない・1回の失敗で止まらない)。
305
+ const loop = createRelayRunner({
306
+ tick: () => runner.tick(),
307
+ nextDelayMs: () => cfg.intervalSec * 1000,
308
+ onError: (err) => {
309
+ const msg = err instanceof Error ? err.message : String(err);
310
+ console.log(`[build] 見回りで例外: ${msg}(次の回にやり直します)`);
311
+ },
312
+ });
313
+ void loop.run();
314
+ // ★耳からの合図が届いたら、60秒を待たずに1周する(届かなくても60秒で回る)。
315
+ const watcher = watchNudges(BUILD_NUDGE_NAME, () => {
316
+ console.log('[build] 合図 → 確認を前倒しします');
317
+ void loop.run();
318
+ });
319
+ const stop = () => {
320
+ loop.stop();
321
+ watcher.stop();
322
+ console.log('\n[build] 停止しました。');
323
+ process.exit(0);
324
+ };
325
+ process.on('SIGINT', stop);
326
+ process.on('SIGTERM', stop);
327
+ }
281
328
  /** package.json の version を返す(読めなければ 'unknown')。dist/cli.js からは ../package.json。 */
282
329
  function readVersion() {
283
330
  try {
@@ -321,6 +368,7 @@ function printUsage() {
321
368
  '使い方:',
322
369
  ' conductor start [-s <site>] [-t <idle|working>] [-a <lastAction>] 現場ごとの常駐(heartbeat+relay)',
323
370
  ' conductor ear この機に1本の「耳」(合図の受け取り役)',
371
+ ' conductor build-runner この機に1本の「司令塔」(試作の段取り役)',
324
372
  ' conductor setup <現場名> [フォルダ] [--profile <default|proto>] 采配くんをこのPCに設置する(初回の1回だけ)',
325
373
  '',
326
374
  '設置の型(--profile。既定は default=今までどおり):',
@@ -348,6 +396,18 @@ function printUsage() {
348
396
  ' ★耳が動いていなくても、各relayは上記の間隔のポーリングでこれまでどおり完全に動きます。',
349
397
  ' CONDUCTOR_NUDGE_DIR 耳→relay の合図ファイル置き場(既定: $XDG_RUNTIME_DIR/conductor)',
350
398
  '',
399
+ '司令塔(試作完走 便⑥ / この機に1本だけ動かす常駐。systemd の conductor-build-runner.service 推奨):',
400
+ ' conductor build-runner は、承認された設計書を60秒ごとに探し、便に分けて管制へ1本ずつ積み、',
401
+ ' 終わるまで見届けます(★同時1本。職人にも tmux にも触りません)。',
402
+ ' 必要な設定(★人が ~/.conductor.env に入れます・値は持ちません):',
403
+ // ★一覧はここに書かない。要る設定の正本は src/build-runner.ts の REQUIRED_ENV。
404
+ // 写すと「案内には載っているのに起動時に別の名前を見る」が起きる。
405
+ ...REQUIRED_ENV.map((e) => ` ${e.name.padEnd(24)}${e.note}`),
406
+ ' 任意:',
407
+ ' CONDUCTOR_BUILD_SITE 便を積む現場名(既定: ai-kana-proto)',
408
+ ' CONDUCTOR_BUILD_DIR 職人の作業フォルダ(既定: ~/projects/<現場名>)',
409
+ ' CONDUCTOR_BUILD_INTERVAL_SEC 見回りの間隔・秒(既定: 60)',
410
+ '',
351
411
  '例:',
352
412
  ' CONDUCTOR_URL=https://<your-conductor-host> \\',
353
413
  ' CONDUCTOR_TOKEN=*** conductor start',
@@ -370,6 +430,11 @@ async function main() {
370
430
  ensureEnvFilePermissions();
371
431
  runEarCommand();
372
432
  break;
433
+ case 'build-runner':
434
+ // 司令塔も同じ接続設定(~/.conductor.env)を読む常駐なので、同じ揃えをかける。
435
+ ensureEnvFilePermissions();
436
+ runBuildRunnerCommand();
437
+ break;
373
438
  case 'setup':
374
439
  // ★引数はそのまま setup.sh へ渡す(judgement は全部インストーラ側)。
375
440
  runSetupCommand(process.argv.slice(3));
@@ -178,6 +178,13 @@ export const PROTO_ALLOWED = [
178
178
  domains: ['api.vercel.com', 'vercel.com'],
179
179
  },
180
180
  ];
181
+ /**
182
+ * 試作の現場の .env に置いてよい項目の名前(★これで全部)。
183
+ *
184
+ * ★職人へ渡す指示文(src/build-prompt.ts)もここから引く。名前を2か所に書くと、
185
+ * 片方を直したときに「指示文だけ古い鍵名を読ませる」ことになる。
186
+ */
187
+ export const PROTO_ENV_KEYS = ['PROTO_API_BASE', 'PROTO_APP_KEY'];
181
188
  /** 試作の現場に置く .env の見本。★本番の鍵の名前を1つも書かない。 */
182
189
  export const PROTO_ENV_EXAMPLE = [
183
190
  '# 試作(proto)の現場の環境変数の見本です。',
@@ -185,11 +192,11 @@ export const PROTO_ENV_EXAMPLE = [
185
192
  '# ★ここに書いてよいのは、試作の口につなぐための2つだけです。',
186
193
  '# 本番のデータベース・送信・課金の鍵は、試作の現場には置きません(置かないから漏れません)。',
187
194
  '#',
188
- '# 使い方: このファイルを .env という名前でコピーし、PROTO_APP_KEY に合言葉を入れてください。',
195
+ `# 使い方: このファイルを .env という名前でコピーし、${PROTO_ENV_KEYS[1]} に合言葉を入れてください。`,
189
196
  '# 合言葉は人が入れます(AI は .env を書けません)。',
190
197
  '',
191
- `PROTO_API_BASE=${PROTO_ENDPOINT.replace(/\/$/, '')}`,
192
- 'PROTO_APP_KEY=',
198
+ `${PROTO_ENV_KEYS[0]}=${PROTO_ENDPOINT.replace(/\/$/, '')}`,
199
+ `${PROTO_ENV_KEYS[1]}=`,
193
200
  '',
194
201
  ].join('\n');
195
202
  /** 重複を作らず、書いた順を保つ。 */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tyhld/conductor",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "采配くん管制の見守りアプリ(conductor-agent)。各PCで常駐し、中央(devlog-tracker)へ定期的に生存報告(heartbeat)を送る常駐CLI。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -851,7 +851,8 @@ step "⑥ systemd 常駐サービスの設定"
851
851
  # 耳を台帳へ載せるのは systemd/ear-install.sh で、下の cleanup_stale_units 呼び出しより
852
852
  # 後に実行される。この順序は test/setup-ear-order.test.ts が固定している。
853
853
  CURRENT_MACHINE_UNITS=(
854
- conductor-ear.service # ADR-066 の耳(このPCに1本・現役)。掃除してはいけない。
854
+ conductor-ear.service # ADR-066 の耳(このPCに1本・現役)。掃除してはいけない。
855
+ conductor-build-runner.service # 試作完走 便⑥ の司令塔(このPCに1本・現役)。同上。
855
856
  )
856
857
 
857
858
  cleanup_stale_units() {