@tyhld/conductor 0.12.37 → 0.12.38

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 (3) hide show
  1. package/dist/cli.js +26 -1
  2. package/dist/relay.js +323 -41
  3. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -13,7 +13,7 @@ import { execFileSync, spawnSync } from 'node:child_process';
13
13
  import { existsSync, readFileSync } from 'node:fs';
14
14
  import { fileURLToPath } from 'node:url';
15
15
  import os from 'node:os';
16
- import { RELAY_FETCH_TIMEOUT_MS, createRelayState, deliveryReport, parseAgentKind, reconnectDelayMs, relayTick, tmuxHasSession, } from './relay.js';
16
+ import { RELAY_FETCH_TIMEOUT_MS, createRelayState, deliveryReport, parseAgentKind, reconnectDelayMs, relayTick, tmuxHasSession, pendingReportIds, sweepProcessedReports, } from './relay.js';
17
17
  import { createRelayRunner } from './relay-runner.js';
18
18
  import { installProcessGuards } from './process-guard.js';
19
19
  import { registerNudgeTarget, unregisterNudgeTarget, watchNudges, } from './nudge.js';
@@ -176,6 +176,14 @@ async function sendReport(cfg) {
176
176
  if (d.queuedCommandId !== null)
177
177
  body.queuedCommandId = d.queuedCommandId;
178
178
  }
179
+ // ★未処理の報告(まだ管制へ渡せていない控え)の指示IDを載せる(便 57c04e6d)。
180
+ // 1件も無ければ載せない=今までと同じ本文。多すぎるときは上限までにして総数を添える。
181
+ const pending = pendingReportIds();
182
+ if (pending.ids.length > 0) {
183
+ body.pendingReports = pending.ids;
184
+ if (pending.total > pending.ids.length)
185
+ body.pendingReportsTotal = pending.total;
186
+ }
179
187
  const time = new Date().toLocaleTimeString();
180
188
  try {
181
189
  const res = await fetch(`${cfg.url}/api/conductor/report`, {
@@ -216,6 +224,23 @@ async function start(args) {
216
224
  const timer = setInterval(() => {
217
225
  void sendReport(cfg);
218
226
  }, cfg.intervalSec * 1000);
227
+ // ★古い控えの片付け(便 57c04e6d)。1時間に1回だけ、処理済みの控えのうち30日より古い物を消す。
228
+ // ★未処理の控えは日数では消さない(渡し切れていない報告を失わないため)。
229
+ // 起動直後にも1回まわす=入れ直した直後から効く。
230
+ const sweep = () => {
231
+ try {
232
+ const removed = sweepProcessedReports();
233
+ if (removed > 0)
234
+ console.log(`[conductor] 古い報告の控えを${removed}件片付けました(処理済み・30日超)`);
235
+ }
236
+ catch (err) {
237
+ // 片付けは最善努力。失敗しても生存報告は止めない。
238
+ console.log(`[conductor] 報告の控えの片付けに失敗: ${err instanceof Error ? err.message : String(err)}`);
239
+ }
240
+ };
241
+ sweep();
242
+ const sweepTimer = setInterval(sweep, 60 * 60 * 1000);
243
+ sweepTimer.unref?.();
219
244
  // ADR-029 relay。CONDUCTOR_RELAY_ENABLED=true のときだけ別インターバルで回す。
220
245
  // heartbeat とは独立。off の間は何も起きない(既存挙動を壊さない)。
221
246
  //
package/dist/relay.js CHANGED
@@ -23,7 +23,7 @@
23
23
  * メニューへ指示を流し込まないための落とし穴ガード専用。中身は読まない。
24
24
  */
25
25
  import { execFileSync } from 'node:child_process';
26
- import { mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
26
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, unlinkSync, utimesSync, writeFileSync, } from 'node:fs';
27
27
  import os from 'node:os';
28
28
  import path from 'node:path';
29
29
  // ───────────────────────────────────────────────────────────────────────────
@@ -1010,9 +1010,22 @@ export function buildSendText(body, site, anchor) {
1010
1010
  `(この説明は管制の確認カードにそのまま表示され、非エンジニアのお客さまが読んで許可を判断します)。\n\n` +
1011
1011
  // ★報告はファイルに書いてもらう(2026-09-07)。画面に出す方式は窓が小さすぎて
1012
1012
  // 長い報告の先頭が流れ、9/5 以降 162 回すべて失敗していた(調査 d1d16a56)。
1013
+ // ★2026-09-21: 頭に6つの欄(instruction_id / site / outcome / created_at / evidence / body)を
1014
+ // 必ず書いてもらう。管制が機械で読めるようにするため(えふさん確定・便 57c04e6d)。
1015
+ // 本文は欄の下にそのまま書けるので、長さも書き方も今までと変わらない。
1013
1016
  `作業がすべて完了したら、まず報告を次のファイルに書いてください(変更ファイル・要点・` +
1014
- `チェック結果を簡潔に): ${reportFileCandidates(commandId)[0]} ` +
1015
- `(フォルダが無ければ作ってください。長さの制限はありません。画面に出す必要はありません)。` +
1017
+ `チェック結果を簡潔に): ${path.join(reportWriteDir(), reportFileName(commandId))} ` +
1018
+ `(フォルダが無ければ作ってください。長さの制限はありません。画面に出す必要はありません)。\n` +
1019
+ `★ファイルの先頭に、次の囲みをそのまま付けてください(1行目から。ここが機械の読み口です)。\n` +
1020
+ `---\n` +
1021
+ `instruction_id: ${commandId}\n` +
1022
+ `site: ${site}\n` +
1023
+ `outcome: pushed または no_change または blocked のどれか1つ\n` +
1024
+ `created_at: 書いた日時(例 2026-09-21T04:20:00+09:00)\n` +
1025
+ `evidence:\n` +
1026
+ ` - 受入の証拠を1行に1つ(例 PR https://github.com/... / CI test pass / main に入った など)\n` +
1027
+ `---\n` +
1028
+ `この囲みの下に、いつもどおりの報告本文を書いてください(本文が空だと未完成と見なされます)。\n` +
1016
1029
  `その後の最終行に必ず ${marker} とだけ出力してください。\n\n` +
1017
1030
  // ★案D(relay非依存の確実な完了通知): 完了したら必ず devlog MCP の conductor_complete を呼ぶ。
1018
1031
  `そして完了したら必ず、devlog の MCP ツール conductor_complete を commandId="${commandId}" で1回だけ呼んでください` +
@@ -1095,34 +1108,67 @@ export function relaySendTmpDirs() {
1095
1108
  /** tmux バッファ名の接頭辞(他人のバッファと衝突させないため現場・PID・時刻で一意にする)。 */
1096
1109
  export const RELAY_SEND_BUFFER_PREFIX = 'conductor-send';
1097
1110
  // ───────────────────────────────────────────────────────────────────────────
1098
- // 報告の受け渡し(★ファイル経由・えふさん決定 2026-09-07)
1111
+ // 報告の受け渡し(★ファイル経由・えふさん決定 2026-09-07/置き場と作法は 2026-09-21 に改訂)
1099
1112
  // ───────────────────────────────────────────────────────────────────────────
1113
+ //
1114
+ // 【なぜ画面から読むのをやめたか(調査 d1d16a56 で確定した事実)】
1115
+ // 職人は全画面表示で動くので tmux から見える行は 16〜34 行しかない(実測: 代替画面=1・
1116
+ // 履歴の行数 0〜222 行/上限 2000 行)。報告が数十行あると、終わりの目印と DONE 行が
1117
+ // 下に残った時点で【始まりの目印はもう窓の外】になり、対が見つからず報告が null になる。
1118
+ // ★9/5 以降の実測: 報告を添えた完了 0 件 / 報告なしの完了 162 件。
1119
+ // =画面から報告を読む保険は一度も動いたことがなかった。だから【画面を読むのをやめて】ファイルにする。
1120
+ //
1121
+ // 【2026-09-21 に何を変えたか(えふさん確定・便 57c04e6d)】
1122
+ // ・置き場を `~/.local/state/conductor/reports` にした(`/tmp` は再起動で消えるため)。
1123
+ // 旧 `/tmp/claude-<uid>/conductor-report` は【読む先としてだけ】残す=既存の控えも拾える。
1124
+ // ・管制へ渡した後も【消さない】。`processed/` へ移して残す(markReportProcessed)。
1125
+ // ・職人が MCP で自分で完了させた場合も、同じ控えを残す(以前はここで捨てていた)。
1126
+ // ・未処理の指示IDは生存報告に載せて管制へ知らせる(pendingReportIds)。
1127
+ // 実体はこのファイルの下(★relay.ts は試験から直に読まれるので、部品を別ファイルに分けない)。
1128
+ /** 処理済みの控えを置く下位フォルダの名前。 */
1129
+ export const PROCESSED_DIR = 'processed';
1130
+ /** 処理済みの控えを残す期間(既定30日)。★未処理はこの期間では消さない。 */
1131
+ export const REPORT_KEEP_PROCESSED_MS = 30 * 24 * 60 * 60 * 1000;
1132
+ /**
1133
+ * 生存報告に載せる未処理の指示IDの上限(★管制側 ai-kana 便と同じ値にすること)。
1134
+ * 超えた分は載せず、総数だけ `pendingReportsTotal` で伝える(本文が太らないように)。
1135
+ */
1136
+ export const PENDING_REPORTS_MAX = 50;
1137
+ /** 報告の結末。conductor_complete の outcome と同じ語にそろえる(人も機械も同じ言い方にする)。 */
1138
+ export const REPORT_OUTCOMES = ['pushed', 'no_change', 'blocked'];
1139
+ /** 必須の欄(1つでも欠けたら「機械可読ではない」と判定する)。 */
1140
+ export const REQUIRED_REPORT_FIELDS = [
1141
+ 'instruction_id',
1142
+ 'site',
1143
+ 'outcome',
1144
+ 'created_at',
1145
+ 'evidence',
1146
+ 'body',
1147
+ ];
1100
1148
  /**
1101
- * 職人が報告を書く場所(1指示につき1ファイル)。★relay はここだけを見る。
1102
- *
1103
- * 【なぜ画面から読むのをやめたか(調査 d1d16a56 で確定した事実)】
1104
- * 職人は全画面表示で動くので tmux から見える行は 16〜34 行しかない(実測: 代替画面=1・
1105
- * 履歴の行数 0〜222 行/上限 2000 行)。報告が数十行あると、終わりの目印と DONE 行が
1106
- * 下に残った時点で【始まりの目印はもう窓の外】になり、対が見つからず報告が null になる。
1107
- * ★9/5 以降の実測: 報告を添えた完了 0 件 / 報告なしの完了 162 件。
1108
- * =画面から報告を読む保険は一度も動いたことがなかった。窓の大きさの問題なので、
1109
- * 目印の書き方をどう工夫しても直らない。だから【画面を読むのをやめて】ファイルにする。
1110
- *
1111
- * 置き場(前から順に試す。理由は writeSendTmpFile と同じ):
1112
- * 第1候補 /tmp/claude-<uid>/conductor-report … 職人の使い捨て置き場と同じ根(鉄則13)
1113
- * 第2候補 os.tmpdir()/conductor-report-<uid> … /tmp が使えない機体の保険
1114
- * 環境変数 CONDUCTOR_REPORT_DIR で1つに固定できる(テスト・特殊な機体向け)。
1149
+ * 報告の置き場(前から順に試す)。
1150
+ * ① `~/.local/state/conductor/reports` … ★正本。再起動で消えない(XDG_STATE_HOME があればそちら)。
1151
+ * ② `/tmp/claude-<uid>/conductor-report` … 旧・読む先としてだけ残す(既存の控えを拾うため)。
1152
+ * ③ `os.tmpdir()/conductor-report-<uid>` … `/tmp` が使えない機体の保険(旧)。
1153
+ * 環境変数 `CONDUCTOR_REPORT_DIR` を指定したときは、その1つだけ(試験・特殊な機体向け)。
1115
1154
  */
1116
- export function relayReportDirs(env = process.env) {
1155
+ export function reportStoreDirs(env = process.env) {
1117
1156
  const explicit = (env.CONDUCTOR_REPORT_DIR ?? '').trim();
1118
1157
  if (explicit)
1119
1158
  return [explicit];
1120
1159
  const uid = typeof process.getuid === 'function' ? process.getuid() : 0;
1160
+ const home = env.HOME ?? os.homedir();
1161
+ const state = (env.XDG_STATE_HOME ?? '').trim() || path.join(home, '.local', 'state');
1121
1162
  return [
1163
+ path.join(state, 'conductor', 'reports'),
1122
1164
  path.join('/tmp', `claude-${uid}`, 'conductor-report'),
1123
1165
  path.join(os.tmpdir(), `conductor-report-${uid}`),
1124
1166
  ];
1125
1167
  }
1168
+ /** 職人に書いてもらう置き場(★正本の1つだけ)。指示文はここを指す。 */
1169
+ export function reportWriteDir(env = process.env) {
1170
+ return reportStoreDirs(env)[0];
1171
+ }
1126
1172
  /**
1127
1173
  * その指示の報告ファイル名。★指示ID で引ける形にする(どの便の報告かを取り違えない)。
1128
1174
  * 指示ID に紛れ込みうる区切り文字は落とす(別の場所を指す名前を作らせない)。
@@ -1131,39 +1177,256 @@ export function reportFileName(commandId) {
1131
1177
  const safe = String(commandId ?? '').replace(/[^A-Za-z0-9_-]/g, '').slice(0, 128);
1132
1178
  return `${safe || 'unknown'}.md`;
1133
1179
  }
1134
- /** 報告ファイルの置き場の候補(前から順に探す)。 */
1135
- function reportFileCandidates(commandId, env) {
1136
- return relayReportDirs(env).map((dir) => path.join(dir, reportFileName(commandId)));
1180
+ /** 未処理の置き場の候補(前から順に探す)。 */
1181
+ function pendingCandidates(commandId, env) {
1182
+ return reportStoreDirs(env).map((dir) => path.join(dir, reportFileName(commandId)));
1183
+ }
1184
+ /** 処理済みの置き場の候補(前から順に探す)。 */
1185
+ function processedCandidates(commandId, env) {
1186
+ return reportStoreDirs(env).map((dir) => path.join(dir, PROCESSED_DIR, reportFileName(commandId)));
1187
+ }
1188
+ /** 中身のあるファイルを読む。無い・空なら null。 */
1189
+ function readIfFilled(file) {
1190
+ try {
1191
+ const text = readFileSync(file, 'utf8').trim();
1192
+ return text.length > 0 ? text : null;
1193
+ }
1194
+ catch {
1195
+ return null;
1196
+ }
1137
1197
  }
1138
1198
  /**
1139
- * その指示の報告を読む(消さない)。無ければ null。
1140
- * ★読むだけで消さないのは、管制へ書けたことを確かめてから消すため
1141
- * (書けなかったのに消すと報告が永久に失われる)。
1199
+ * その指示の報告を読む(★消さない)。未処理が無ければ処理済みの控えも見る。
1200
+ * 返すのは【管制へ渡す本文】=front matter を外した本体(古い形は全文)。
1142
1201
  */
1143
1202
  export function readReport(commandId, env) {
1144
- for (const file of reportFileCandidates(commandId, env)) {
1203
+ const parsed = readParsedReport(commandId, env);
1204
+ return parsed === null ? null : parsed.body;
1205
+ }
1206
+ /** その指示の報告を、欄ごとに解析して読む(未処理→処理済みの順に探す)。 */
1207
+ export function readParsedReport(commandId, env) {
1208
+ for (const file of [...pendingCandidates(commandId, env), ...processedCandidates(commandId, env)]) {
1209
+ const text = readIfFilled(file);
1210
+ if (text !== null)
1211
+ return parseReport(text);
1212
+ }
1213
+ return null;
1214
+ }
1215
+ /** その指示の報告が、いまどの段にあるか。 */
1216
+ export function reportPhase(commandId, env) {
1217
+ for (const file of pendingCandidates(commandId, env)) {
1218
+ if (readIfFilled(file) !== null)
1219
+ return 'pending';
1220
+ }
1221
+ for (const file of processedCandidates(commandId, env)) {
1222
+ if (readIfFilled(file) !== null)
1223
+ return 'processed';
1224
+ }
1225
+ return 'none';
1226
+ }
1227
+ /**
1228
+ * 管制へ渡し終えた報告を【処理済み】にする(★消さない・削除しない)。
1229
+ * 同じ置き場の `processed/` へ移すだけ。移した時刻を更新時刻に刻む(30日の数え始め)。
1230
+ * 返り: 処理済みにできたら true(元から無い・既に処理済みなら false)。
1231
+ */
1232
+ export function markReportProcessed(commandId, env, now = Date.now()) {
1233
+ let moved = false;
1234
+ for (const file of pendingCandidates(commandId, env)) {
1235
+ if (!existsSync(file))
1236
+ continue;
1237
+ const dest = path.join(path.dirname(file), PROCESSED_DIR, path.basename(file));
1145
1238
  try {
1146
- const text = readFileSync(file, 'utf8').trim();
1147
- if (text.length > 0)
1148
- return text;
1239
+ mkdirSync(path.dirname(dest), { recursive: true, mode: 0o700 });
1240
+ // ★同じ置き場の中で動かす=別のディスクをまたがないので、途中で半分になることがない。
1241
+ renameSync(file, dest);
1242
+ // 30日の数え始めを「処理済みにした時刻」にそろえる(書いた時刻ではない)。
1243
+ const sec = now / 1000;
1244
+ utimesSync(dest, sec, sec);
1245
+ moved = true;
1149
1246
  }
1150
1247
  catch {
1151
- // 無い・読めない → 次の候補へ(報告が無いこと自体は異常ではない)
1248
+ // 移せなかったら触らない(次の周でやり直せる。★消すよりは残す方へ倒す)。
1152
1249
  }
1153
1250
  }
1154
- return null;
1251
+ return moved;
1155
1252
  }
1156
- /** その指示の報告ファイルを片付ける(★同じ報告を二度送らないため)。 */
1157
- export function discardReport(commandId, env) {
1158
- for (const file of reportFileCandidates(commandId, env)) {
1253
+ /**
1254
+ * まだ管制へ渡していない報告の指示ID(古い順)。★生存報告に載せて管制へ知らせるための一覧。
1255
+ * - `processed/` の中は数えない(もう渡した物)。
1256
+ * - 同じ指示IDが複数の置き場に在っても1件に畳む。
1257
+ * - 上限を超えた分は `ids` に載せず、`total` だけで伝える。
1258
+ */
1259
+ export function pendingReportIds(env, limit = PENDING_REPORTS_MAX) {
1260
+ const seen = new Map(); // 指示ID → 書かれた時刻(ms)
1261
+ for (const dir of reportStoreDirs(env)) {
1262
+ let names;
1159
1263
  try {
1160
- unlinkSync(file);
1264
+ names = readdirSync(dir);
1161
1265
  }
1162
1266
  catch {
1163
- // 無ければ何もしない
1267
+ continue; // 置き場がまだ無い=未処理も無い
1268
+ }
1269
+ for (const name of names) {
1270
+ if (!name.endsWith('.md'))
1271
+ continue;
1272
+ const file = path.join(dir, name);
1273
+ let mtime;
1274
+ try {
1275
+ const st = statSync(file);
1276
+ if (!st.isFile() || st.size === 0)
1277
+ continue;
1278
+ mtime = st.mtimeMs;
1279
+ }
1280
+ catch {
1281
+ continue;
1282
+ }
1283
+ const id = name.slice(0, -3);
1284
+ const prev = seen.get(id);
1285
+ if (prev === undefined || mtime < prev)
1286
+ seen.set(id, mtime);
1164
1287
  }
1165
1288
  }
1289
+ const sorted = [...seen.entries()].sort((a, b) => a[1] - b[1]).map(([id]) => id);
1290
+ const max = Math.max(0, Math.floor(limit));
1291
+ return { ids: sorted.slice(0, max), total: sorted.length };
1166
1292
  }
1293
+ /**
1294
+ * 古い控えを片付ける。★消すのは `processed/` の中だけ(未処理は何日たっても消さない)。
1295
+ * 返り: 消した件数。
1296
+ */
1297
+ export function sweepProcessedReports(env, now = Date.now(), maxAgeMs = REPORT_KEEP_PROCESSED_MS) {
1298
+ let removed = 0;
1299
+ for (const dir of reportStoreDirs(env)) {
1300
+ const done = path.join(dir, PROCESSED_DIR);
1301
+ let names;
1302
+ try {
1303
+ names = readdirSync(done);
1304
+ }
1305
+ catch {
1306
+ continue;
1307
+ }
1308
+ for (const name of names) {
1309
+ if (!name.endsWith('.md'))
1310
+ continue;
1311
+ const file = path.join(done, name);
1312
+ try {
1313
+ const st = statSync(file);
1314
+ if (!st.isFile())
1315
+ continue;
1316
+ if (now - st.mtimeMs <= maxAgeMs)
1317
+ continue;
1318
+ unlinkSync(file);
1319
+ removed += 1;
1320
+ }
1321
+ catch {
1322
+ // 読めない・消せないなら触らない
1323
+ }
1324
+ }
1325
+ }
1326
+ return removed;
1327
+ }
1328
+ /**
1329
+ * 報告の文字列を欄ごとに解く。
1330
+ *
1331
+ * 【なぜ front matter(`---` の囲み)にしたか=機械が確実に読める形の選び方】
1332
+ * ・職人は報告を【ヒアドキュメントで一気に書く】。JSON だと本文の改行・引用符・日本語の記号で
1333
+ * 壊れやすく、壊れた瞬間に報告そのものが読めなくなる(本文は数千〜2万字ある)。
1334
+ * ・front matter なら「頭の数行だけが欄・残りは全部本文」なので、本文に何が書かれていても
1335
+ * 欄の解釈は壊れない。欄が無い古い報告も【本文だけ在る】として同じ関数で読める。
1336
+ * ・外部の部品(YAML 読み)を足さずに読める=配布物が太らない。
1337
+ */
1338
+ export function parseReport(text) {
1339
+ const raw = String(text ?? '');
1340
+ const lines = raw.replace(/^/, '').split('\n');
1341
+ let i = 0;
1342
+ while (i < lines.length && lines[i].trim() === '')
1343
+ i += 1;
1344
+ const empty = {
1345
+ instructionId: null,
1346
+ site: null,
1347
+ outcome: null,
1348
+ createdAt: null,
1349
+ evidence: [],
1350
+ body: raw.trim(),
1351
+ missing: [],
1352
+ hasHeader: false,
1353
+ };
1354
+ if (i >= lines.length || lines[i].trim() !== '---') {
1355
+ return { ...empty, missing: missingOf(empty) };
1356
+ }
1357
+ const fields = new Map();
1358
+ const evidence = [];
1359
+ let lastKey = '';
1360
+ let closed = false;
1361
+ let j = i + 1;
1362
+ for (; j < lines.length; j += 1) {
1363
+ const line = lines[j];
1364
+ if (line.trim() === '---') {
1365
+ closed = true;
1366
+ break;
1367
+ }
1368
+ const bullet = /^\s*-\s+(.*)$/.exec(line);
1369
+ if (bullet !== null) {
1370
+ // 箇条書きは直前の欄の続き。いまは evidence だけが複数行を持つ。
1371
+ if (lastKey === 'evidence' && bullet[1].trim() !== '')
1372
+ evidence.push(bullet[1].trim());
1373
+ continue;
1374
+ }
1375
+ const kv = /^([A-Za-z_][A-Za-z0-9_]*)\s*:\s*(.*)$/.exec(line);
1376
+ if (kv === null)
1377
+ continue; // 読めない行は捨てる(欄の解釈を壊さない)
1378
+ lastKey = kv[1].toLowerCase();
1379
+ const value = kv[2].trim();
1380
+ if (lastKey === 'evidence') {
1381
+ if (value !== '')
1382
+ evidence.push(value);
1383
+ continue;
1384
+ }
1385
+ fields.set(lastKey, value);
1386
+ }
1387
+ if (!closed) {
1388
+ // 閉じていない=front matter として読まない(全文を本文として扱う)。
1389
+ return { ...empty, missing: missingOf(empty) };
1390
+ }
1391
+ const outcomeRaw = (fields.get('outcome') ?? '').trim();
1392
+ const outcome = REPORT_OUTCOMES.includes(outcomeRaw)
1393
+ ? outcomeRaw
1394
+ : null;
1395
+ const parsed = {
1396
+ instructionId: nonEmpty(fields.get('instruction_id')),
1397
+ site: nonEmpty(fields.get('site')),
1398
+ outcome,
1399
+ createdAt: nonEmpty(fields.get('created_at')),
1400
+ evidence,
1401
+ body: lines.slice(j + 1).join('\n').trim(),
1402
+ missing: [],
1403
+ hasHeader: true,
1404
+ };
1405
+ return { ...parsed, missing: missingOf(parsed) };
1406
+ }
1407
+ function nonEmpty(v) {
1408
+ const s = (v ?? '').trim();
1409
+ return s === '' ? null : s;
1410
+ }
1411
+ /** 欠けている必須の欄を並べる(空なら新しい形=機械可読)。 */
1412
+ function missingOf(r) {
1413
+ const missing = [];
1414
+ if (r.instructionId === null)
1415
+ missing.push('instruction_id');
1416
+ if (r.site === null)
1417
+ missing.push('site');
1418
+ if (r.outcome === null)
1419
+ missing.push('outcome');
1420
+ if (r.createdAt === null)
1421
+ missing.push('created_at');
1422
+ if (r.evidence.length === 0)
1423
+ missing.push('evidence');
1424
+ if (r.body.trim() === '')
1425
+ missing.push('body');
1426
+ return missing;
1427
+ }
1428
+ /** 報告の置き場(前から順に探す)。★名前は従来のまま(中身は reportStoreDirs)。 */
1429
+ export const relayReportDirs = reportStoreDirs;
1167
1430
  /** 本文を書いた一時ファイルを作ってパスを返す。どこにも書けなければ例外。 */
1168
1431
  function writeSendTmpFile(text) {
1169
1432
  let lastErr = new Error('一時ファイルの置き場が1つも作れませんでした');
@@ -1415,23 +1678,33 @@ export async function patchStatus(cfg, id, status, report) {
1415
1678
  * 中央へ1回だけ状態を尋ねて確かめる。尋ねられない(不通)ときは送る側に倒す
1416
1679
  * =完了が落ちるより、同じ報告が2度書かれるほうが害が小さい(中央は上書き保存する)。
1417
1680
  *
1418
- * 【後片付け】管制へ書けたときだけ報告ファイルを消す。書けなければ残す=次tickでやり直せる。
1681
+ * 【後片付け】管制へ書けたときだけ報告を【処理済み】にする(★消さない・2026-09-21)。
1682
+ * 書けなければ未処理のまま残す=次tickでやり直せるし、管制も生存報告で気づける。
1683
+ *
1684
+ * 【★冪等(二重完了にしない)】報告が既に処理済みなら、この便はもう渡し終えている。
1685
+ * 中央へ問い合わせる前にそこで打ち切る=MCP と報告ファイルの二経路から同じ便が来ても1回だけ扱う。
1419
1686
  * 返り: 追跡を畳んでよいか(true = 完了が確定した)。
1420
1687
  */
1421
1688
  export async function completeWithReport(cfg, id, logLine = log) {
1689
+ // ★冪等の要: 処理済みの控えが在る=この便はもう渡してある。何もしない(二重完了にしない)。
1690
+ if (reportPhase(id) === 'processed') {
1691
+ logLine(`site=${cfg.site} id=${id} の報告は処理済みです→送りません(二重完了にしない)`);
1692
+ return true;
1693
+ }
1422
1694
  const already = await fetchCommandStatus(cfg, id);
1423
1695
  if (isTerminalCommandStatus(already)) {
1424
1696
  // 職人が自分で完了させていた。★relay は二重に送らない(報告も上書きしない)。
1425
- discardReport(id);
1697
+ // ★控えは消さずに処理済みとして残す(MCP 経由でも同じ控えが残る・えふさん確定 2026-09-21)。
1698
+ markReportProcessed(id);
1426
1699
  logLine(`site=${cfg.site} id=${id} は職人が自分で完了させていた(status=${already})` +
1427
- '→relayからは送りません(報告の置き場だけ片付けました)');
1700
+ '→relayからは送りません(報告は控えとして処理済みにしました)');
1428
1701
  return true;
1429
1702
  }
1430
1703
  const report = readReport(id);
1431
1704
  if (!(await patchStatus(cfg, id, 'done', report)))
1432
1705
  return false;
1433
1706
  if (report !== null)
1434
- discardReport(id); // ★書けたら片付ける(同じ報告を二度送らない)
1707
+ markReportProcessed(id); // ★消さずに処理済みへ(同じ報告を二度送らない)
1435
1708
  logLine(`site=${cfg.site} id=${id} done` +
1436
1709
  (report !== null ? `(報告添付・${report.length}文字)` : '(報告ファイルなし=完了のみ)'));
1437
1710
  return true;
@@ -1745,11 +2018,20 @@ export async function keepaliveTick(cfg, state, deps = {}) {
1745
2018
  return;
1746
2019
  }
1747
2020
  if (action === 'done') {
2021
+ // ★冪等(2026-09-21): 報告が既に処理済み=この便はもう渡してある。送らずに畳むだけ。
2022
+ // 実測 2026-09-20: 職人が MCP で完了した便に、見張りが画面の DONE でもう一度完了を送っていた。
2023
+ if (reportPhase(ka.id) === 'processed') {
2024
+ logLine(`site=${cfg.site} id=${ka.id} 見張り終了: 報告は処理済みでした` +
2025
+ '→送らずに畳みます(二重完了にしない)');
2026
+ noteBusyEdge(state, cfg.site, ka.id, false, logLine);
2027
+ state.keepalive = null;
2028
+ return;
2029
+ }
1748
2030
  // 完了は従来の経路(画面の DONE 行)にそのまま乗せる。★報告は職人が書いたファイルから読む。
1749
2031
  const report = readReport(ka.id);
1750
2032
  if (await complete(cfg, ka.id, report)) {
1751
2033
  if (report !== null)
1752
- discardReport(ka.id); // ★書けたら片付ける(二度送らない)
2034
+ markReportProcessed(ka.id); // ★消さずに処理済みへ(二度送らない)
1753
2035
  logLine(`site=${cfg.site} id=${ka.id} 見張り終了: 画面のDONEを検出し完了を報告` +
1754
2036
  `${report != null ? '(報告添付)' : ''}`);
1755
2037
  // ★終わったら必ず手を下ろす(挙げっぱなしにすると現場が長くふさがる)。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tyhld/conductor",
3
- "version": "0.12.37",
3
+ "version": "0.12.38",
4
4
  "description": "采配くん管制の見守りアプリ(conductor-agent)。各PCで常駐し、中央(devlog-tracker)へ定期的に生存報告(heartbeat)を送る常駐CLI。",
5
5
  "type": "module",
6
6
  "bin": {