@tyhld/conductor 0.12.94 → 0.12.96

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 (2) hide show
  1. package/dist/relay.js +340 -10
  2. package/package.json +1 -1
package/dist/relay.js CHANGED
@@ -532,6 +532,31 @@ export function markDelivered(state, id) {
532
532
  // 純粋関数(外部I/Oなし=単体テスト/ドライランしやすい)
533
533
  // ───────────────────────────────────────────────────────────────────────────
534
534
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
535
+ // ── 行ごとの記録(jsonl)の読み方 ───────────────────────────────────────────
536
+ //
537
+ // 【なぜ要るか(実測 2026-10-04・0.12.95 の公開の流れ)】
538
+ // 行ごとの記録は「書き手が1行を足している最中」に読めてしまう。1行が数千文字あると、
539
+ // 書き込みが途中まで見える瞬間があり、その切れた行を JSON として読むと必ず失敗する
540
+ // (SyntaxError: Unterminated string in JSON)。
541
+ // 【考え方(これ1つで通す)】★読むのは【最後の改行まで】。
542
+ // 改行で終わっていない末尾の行は「まだ書きかけ」として扱い、次に読むときに拾う。
543
+ // =落ちない・取りこぼさない。見張りの流し読み(view-stream の readNew)と同じ考え。
544
+ // 【どこに効かせるか】★読んだあとに【消す】所(Codex の受け皿)だけは、これが無いと
545
+ // 書きかけの1行を捨てたうえにファイルを消すので、その完了が永久に失われる。
546
+ // 一方、行ごとに try/catch で飛ばして次の回に読み直す所(受領確認・受付の確かめ)は
547
+ // それ自体で落ちないので変えない(最後の1行に改行が無い記録を読み落とさないため)。
548
+ /**
549
+ * 書き終わった行(改行で終わっている行)だけを古い順に返す純粋関数。
550
+ * 末尾の書きかけ(改行で終わっていない残り)は返さない(=次に読むときに拾う)。
551
+ */
552
+ export function completeJsonlLines(text) {
553
+ const end = text.lastIndexOf('\n');
554
+ return end < 0 ? [] : text.slice(0, end).split('\n');
555
+ }
556
+ /** 末尾に書きかけの行(改行で終わっていない残り)が在るか。純粋関数。 */
557
+ export function hasPartialJsonlTail(text) {
558
+ return text.length > 0 && !text.endsWith('\n');
559
+ }
535
560
  /** 末尾の空行を除いた「末尾 RELAY_TAIL_LINES 行」を文字列で返す。静止比較の単位。 */
536
561
  export function tailOf(text) {
537
562
  const lines = text.replace(/\r/g, '').split('\n');
@@ -1021,10 +1046,18 @@ export function codexNotifyDir(env = process.env) {
1021
1046
  const home = (env.HOME ?? '').trim();
1022
1047
  return home ? path.join(home, '.tyhld', 'codex-notify') : '';
1023
1048
  }
1049
+ /**
1050
+ * 書きかけの行が末尾に在るとき、書き終わるのを待つ長さ。これを過ぎても改行が来なければ
1051
+ * 書き手が落ちた残骸とみなし、今までどおり捨てて進む(=受け皿が詰まらない)。
1052
+ */
1053
+ const CODEX_NOTICE_PARTIAL_WAIT_MS = 5_000;
1024
1054
  /**
1025
1055
  * 受け皿から「まだ渡していない完了」を1件取り出し、ファイルを空にする(=二重に使わない)。
1026
- * 返り: 最新の1件 / null(無い・読めない)。
1056
+ * 返り: 最新の1件 / null(無い・読めない・まだ書きかけ)。
1027
1057
  * ★読めない・壊れている行は捨てる。relay を止めない(フックと同じ作法)。
1058
+ * ★ただし【書きかけの行】は捨てない。捨てて消すと、書き終わったその1行=完了の知らせが
1059
+ * 永久に失われる(受け口は1件を1行で書き、書き込みは途中まで見えることがある)。
1060
+ * 書きかけが在る間は何も取らずに次の回へ回す(ファイルも消さない)。
1028
1061
  */
1029
1062
  export function takeCodexNotice(site, env = process.env) {
1030
1063
  const dir = codexNotifyDir(env);
@@ -1038,7 +1071,18 @@ export function takeCodexNotice(site, env = process.env) {
1038
1071
  catch {
1039
1072
  return null; // まだ1件も来ていない
1040
1073
  }
1041
- const lines = raw.split('\n').map((l) => l.trim()).filter((l) => l.length > 0);
1074
+ if (hasPartialJsonlTail(raw)) {
1075
+ let writing = false;
1076
+ try {
1077
+ writing = Date.now() - statSync(file).mtimeMs < CODEX_NOTICE_PARTIAL_WAIT_MS;
1078
+ }
1079
+ catch {
1080
+ writing = false; // 時刻が読めなければ、今までどおり捨てて進む
1081
+ }
1082
+ if (writing)
1083
+ return null; // 書き終わってから、次の回で拾う
1084
+ }
1085
+ const lines = completeJsonlLines(raw).map((l) => l.trim()).filter((l) => l.length > 0);
1042
1086
  let latest = null;
1043
1087
  for (const line of lines) {
1044
1088
  try {
@@ -1064,6 +1108,215 @@ export function takeCodexNotice(site, env = process.env) {
1064
1108
  }
1065
1109
  return latest;
1066
1110
  }
1111
+ // ───────────────────────────────────────────────────────────────────────────
1112
+ // Codex の最後の返事に書いてもらう「機械で読める囲み」(便 cfa9eeec)
1113
+ //
1114
+ // 【なぜ要るか(実測 2026-10-04・codex-cli 0.160.0・便 f8ac8674 / 4c9f5049)】
1115
+ // Codex の現場へ配る便の末尾が Claude 用のままで、Codex にはできない2つを求めていた:
1116
+ // ① ai-kana の MCP(conductor_complete)… Codex にはつながっていない=「利用可能なツールに無い」。
1117
+ // 毎便その断りが報告に入り、結果(outcome・PR 番号・次の独立作業候補)が管制に入らなかった。
1118
+ // ② 作業フォルダの外(~/.local/state/conductor/reports/)への報告ファイル … 囲いが止める
1119
+ // (Read-only file system)。毎回書こうとして失敗していた。
1120
+ // どちらも「道具を増やす」のではなく、【いま既に通っている道】=Codex の notify で届く
1121
+ // 「最後の返事」に機械で読める囲みを書いてもらい、配り役がそれを読んで報告ファイルを組み立てる。
1122
+ //
1123
+ // 【★受け側(管制)の口は1つも変えない】配り役が書く報告ファイルは、職人が書く物とまったく同じ形
1124
+ // (6欄の囲み+本文)。だから完了の道(completeWithReport)・回収の道(sendPendingReportFiles)・
1125
+ // 形の確かめ(reportDelivery)は1行も変わらない。
1126
+ //
1127
+ // 【★囲みが無い・読めないときは今までと同じ動きに倒す】最後の返事をそのまま報告として渡し、
1128
+ // 便を完了にする(実測で通っていた道を塞がない)。
1129
+ // ───────────────────────────────────────────────────────────────────────────
1130
+ /** 囲みの始まりの印(★Codex の便の配達文と同じ字面を使う)。 */
1131
+ export const CODEX_REPORT_BEGIN = '=== CONDUCTOR_REPORT ===';
1132
+ /**
1133
+ * 囲みの終わりの印。
1134
+ * ★`CONDUCTOR_REPORT_END` にしない理由: 画面に報告を出させていた昔の目印(`REPORT_END`)を
1135
+ * 字面で含んでしまい、「古い指示が残っていないか」を見る検査と紛れるため(2026-09-07 に廃止した方式)。
1136
+ */
1137
+ export const CODEX_REPORT_END = '=== END_CONDUCTOR_REPORT ===';
1138
+ /**
1139
+ * 最後の返事から囲みの中身を切り出す(★印の行は含めない)。見つからなければ null。
1140
+ * - 囲みが閉じていなければ、返事の最後までを囲みの中身として読む(途中で切れた返事を救う)。
1141
+ * - 印の行に飾り(``` ・ ** ・ # ・ 引用の >)が付いていても読む(会話の返事は飾られやすい)。
1142
+ * 返り: inner=囲みの中の行/rest=囲みを外した残り(=報告の本文になる)。
1143
+ */
1144
+ export function sliceCodexReportBlock(reply) {
1145
+ const lines = String(reply ?? '').replace(/\r/g, '').split('\n');
1146
+ const begin = lines.findIndex((l) => isCodexReportMark(l, 'CONDUCTOR_REPORT'));
1147
+ if (begin < 0)
1148
+ return null;
1149
+ let end = -1;
1150
+ for (let i = begin + 1; i < lines.length; i += 1) {
1151
+ if (isCodexReportMark(lines[i], 'END_CONDUCTOR_REPORT')) {
1152
+ end = i;
1153
+ break;
1154
+ }
1155
+ }
1156
+ const inner = lines.slice(begin + 1, end < 0 ? lines.length : end);
1157
+ const rest = [...lines.slice(0, begin), ...(end < 0 ? [] : lines.slice(end + 1))]
1158
+ .join('\n')
1159
+ .trim();
1160
+ return { inner, rest };
1161
+ }
1162
+ /** 印の行か(飾り・前後の `=` を落として、語そのものと突き合わせる)。 */
1163
+ function isCodexReportMark(line, word) {
1164
+ const bare = String(line ?? '')
1165
+ .trim()
1166
+ .replace(/^[`*#>\s]+/, '')
1167
+ .replace(/[`*\s]+$/, '')
1168
+ .replace(/^=+/, '')
1169
+ .replace(/=+$/, '')
1170
+ .trim()
1171
+ .toUpperCase();
1172
+ return bare === word;
1173
+ }
1174
+ /**
1175
+ * 囲みの中身を欄ごとに読む(★読み方は parseReport の囲みと同じ=「key: 値」と「- 」の箇条書き)。
1176
+ * 囲みが無ければ null。
1177
+ */
1178
+ export function parseCodexReportBlock(reply) {
1179
+ const block = sliceCodexReportBlock(reply);
1180
+ if (block === null)
1181
+ return null;
1182
+ const fields = new Map();
1183
+ const evidence = [];
1184
+ let lastKey = '';
1185
+ for (const line of block.inner) {
1186
+ const bullet = /^\s*[-*]\s+(.*)$/.exec(line);
1187
+ if (bullet !== null) {
1188
+ if (lastKey === 'evidence' && bullet[1].trim() !== '')
1189
+ evidence.push(bullet[1].trim());
1190
+ continue;
1191
+ }
1192
+ const kv = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*:\s*(.*)$/.exec(line);
1193
+ if (kv === null)
1194
+ continue; // 読めない行は捨てる(欄の解釈を壊さない)
1195
+ lastKey = kv[1].toLowerCase();
1196
+ const value = kv[2].trim();
1197
+ if (lastKey === 'evidence') {
1198
+ if (value !== '')
1199
+ evidence.push(value);
1200
+ continue;
1201
+ }
1202
+ fields.set(lastKey, value);
1203
+ }
1204
+ return {
1205
+ instructionId: nonEmpty(fields.get('instruction_id')),
1206
+ site: nonEmpty(fields.get('site')),
1207
+ outcome: pickOutcome(fields.get('outcome')),
1208
+ createdAt: nonEmpty(fields.get('created_at')),
1209
+ evidence,
1210
+ prNumber: pickPrNumber(fields.get('pr_number')),
1211
+ nextWork: nonEmpty(fields.get('next_work')),
1212
+ rest: block.rest,
1213
+ };
1214
+ }
1215
+ /**
1216
+ * outcome の語を1つだけ取り出す(★2つ以上書かれていたら「読めない」に倒す)。
1217
+ * 配達文の説明文(「pushed か no_change か blocked のどれか1つ」)をそのまま写した返事を
1218
+ * pushed と読んでしまわないため。
1219
+ */
1220
+ function pickOutcome(raw) {
1221
+ const text = String(raw ?? '');
1222
+ const hit = REPORT_OUTCOMES.filter((o) => new RegExp(`(^|[^a-z_])${o}([^a-z_]|$)`).test(text));
1223
+ return hit.length === 1 ? hit[0] : null;
1224
+ }
1225
+ /** PR 番号(数字だけ)。「なし」「-」など数字が無ければ null。 */
1226
+ function pickPrNumber(raw) {
1227
+ const m = /(\d{1,7})/.exec(String(raw ?? ''));
1228
+ if (m === null)
1229
+ return null;
1230
+ const n = Number(m[1]);
1231
+ return Number.isSafeInteger(n) && n > 0 ? n : null;
1232
+ }
1233
+ /** 囲みの instruction_id が、いま追っている便の物か(頭だけ書かれていても通す)。 */
1234
+ export function codexReportIdMatches(blockId, commandId) {
1235
+ if (blockId === null)
1236
+ return true; // 書かれていない=配り役が知っている便IDで埋める
1237
+ const got = blockId.trim();
1238
+ if (got === commandId)
1239
+ return true;
1240
+ return got.length >= 8 && commandId.startsWith(got);
1241
+ }
1242
+ /**
1243
+ * 囲みから【職人が書く物とまったく同じ形の報告】を組み立てる。読めなければ null。
1244
+ *
1245
+ * 【★読めたと認める条件】囲みが在る/instruction_id が食い違わない/site が食い違わない/
1246
+ * outcome が3つの語のどれか1つ/evidence が1件以上。
1247
+ * ★instruction_id・site は【配り役が知っている値】で書く(職人の写し間違いで別の便の報告にしない)。
1248
+ * 【created_at が無いとき】配り役が受け取った時刻を入れ、入れたことを本文に1行で書く(作り話にしない)。
1249
+ */
1250
+ export function buildCodexReportText(reply, opts) {
1251
+ const block = parseCodexReportBlock(reply);
1252
+ if (block === null)
1253
+ return null;
1254
+ if (!codexReportIdMatches(block.instructionId, opts.commandId))
1255
+ return null;
1256
+ if (block.site !== null && block.site !== opts.site)
1257
+ return null;
1258
+ if (block.outcome === null)
1259
+ return null;
1260
+ if (block.evidence.length === 0)
1261
+ return null;
1262
+ const filledAt = block.createdAt === null ? new Date(opts.now ?? Date.now()).toISOString() : null;
1263
+ const head = [
1264
+ '---',
1265
+ `instruction_id: ${opts.commandId}`,
1266
+ `site: ${opts.site}`,
1267
+ `outcome: ${block.outcome}`,
1268
+ `created_at: ${block.createdAt ?? filledAt}`,
1269
+ 'evidence:',
1270
+ ...block.evidence.map((e) => ` - ${e}`),
1271
+ '---',
1272
+ '',
1273
+ ];
1274
+ const body = [
1275
+ '(この報告は、職人の最後の返事の囲みから配り役が組み立てました)',
1276
+ `結末(outcome): ${block.outcome}`,
1277
+ `PR番号: ${block.prNumber === null ? 'なし' : block.prNumber}`,
1278
+ `次の独立作業候補: ${block.nextWork ?? '(囲みに書かれていません)'}`,
1279
+ ...(filledAt === null ? [] : [`created_at は囲みに無かったので、配り役が受け取った時刻を入れました`]),
1280
+ '',
1281
+ block.rest,
1282
+ ];
1283
+ const text = `${head.join('\n')}${body.join('\n')}`.trim();
1284
+ // ★形が整っていない物は書かない(書きかけを完了の材料にしない・便 461be339 と同じ判定を使う)。
1285
+ return reportShapeMissing(text).length === 0 ? text : null;
1286
+ }
1287
+ /**
1288
+ * Codex の最後の返事の囲みを読み、報告ファイル(職人が書く物と同じ形)を置く。
1289
+ * - 既に報告ファイルが在るときは【上書きしない】(職人が書けた現場を壊さない)。
1290
+ * - 囲みが無い・読めない・書けないときは wrote:false=呼び出し側は今までの動きに倒す。
1291
+ */
1292
+ export function intakeCodexReport(reply, opts) {
1293
+ const env = opts.env;
1294
+ if (reportPhase(opts.commandId, env) !== 'none') {
1295
+ return { wrote: false, file: null, note: '報告ファイルが既にあるので囲みからは作りません' };
1296
+ }
1297
+ const text = buildCodexReportText(reply, { commandId: opts.commandId, site: opts.site, now: opts.now });
1298
+ if (text === null) {
1299
+ return {
1300
+ wrote: false,
1301
+ file: null,
1302
+ note: '最後の返事に機械で読める囲みが無い(または読めない)ので、返事をそのまま報告として渡します',
1303
+ };
1304
+ }
1305
+ const file = path.join(reportWriteDir(env), reportFileName(opts.commandId));
1306
+ try {
1307
+ mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
1308
+ writeFileSync(file, `${text}\n`, { encoding: 'utf8', mode: 0o600 });
1309
+ }
1310
+ catch (err) {
1311
+ return {
1312
+ wrote: false,
1313
+ file: null,
1314
+ note: '囲みは読めましたが報告ファイルを置けませんでした(返事をそのまま報告として渡します): ' +
1315
+ (err instanceof Error ? err.message : String(err)),
1316
+ };
1317
+ }
1318
+ return { wrote: true, file, note: `最後の返事の囲みから報告ファイルを組み立てました: ${file}` };
1319
+ }
1067
1320
  export function sliceForDoneScan(text, anchor, wide) {
1068
1321
  const below = sliceBelowAnchor(text, anchor);
1069
1322
  if (below !== null)
@@ -1243,8 +1496,9 @@ export function buildSendText(body, site, anchor, kind = 'claude', context) {
1243
1496
  ? anchor.slice(SEND_ANCHOR_PREFIX.length + 1)
1244
1497
  : anchor;
1245
1498
  // ★Claude の職人には「便の終え方」の1行だけを付ける(書き方の正本は鉄則の「便の終え方」の節)。
1246
- // ★Codex の職人には今までどおり全文を付ける。鉄則(~/.claude/skills)は Codex には届かないため。
1247
- const guide = kind === 'codex' ? fullCompletionGuide(site, commandId) : shortCompletionLine(site, commandId);
1499
+ // ★Codex の職人には Codex にできることだけを頼む全文を付ける(便 cfa9eeec・codexCompletionGuide)。
1500
+ // 鉄則(~/.claude/skills)は Codex には届かないので、全文で渡すのは今までどおり。
1501
+ const guide = kind === 'codex' ? codexCompletionGuide(site, commandId) : shortCompletionLine(site, commandId);
1248
1502
  const full = `${body}\n\n${dispatchContextBlock(context)}${guide}` +
1249
1503
  // アンカー(管理用ジョブ印)は必ず本文の最後に置く。完了検出の境界として使う。
1250
1504
  `(管理用ジョブ印・変更不要: ${anchor})`;
@@ -1290,9 +1544,14 @@ export function shortCompletionLine(site, commandId) {
1290
1544
  `完了の知らせ: conductor_complete を commandId="${commandId}" で1回だけ(nextWork は必ず書く)。`);
1291
1545
  }
1292
1546
  /**
1293
- * 全文の完了案内(★Codex の職人向け・Claude 向けは shortCompletionLine)。
1294
- * ★ここに書いてある決まりは、鉄則の「便の終え方」の節と中身を同じに保つ
1295
- * (試験 relay-finish-guide-equivalence が1つずつ突き合わせる)。
1547
+ * 便の終え方の【全文=正本の物差し】。
1548
+ *
1549
+ * ★この全文は、もう誰にも送らない(便 cfa9eeec)。本物は次の2つ:
1550
+ * ・Claude の職人 … 1行(shortCompletionLine)+鉄則の「便の終え方」の節
1551
+ * ・Codex の職人 … Codex にできることだけを頼む全文(codexCompletionGuide)
1552
+ * ★残してある理由: 「寄せる前の決まりを1つも失っていない」ことを測る物差しだから
1553
+ * (試験 relay-finish-guide-equivalence が、この全文と「1行+鉄則の節」を1つずつ突き合わせる)。
1554
+ * ここに書いてある決まりは、鉄則の「便の終え方」の節と中身を同じに保つ。
1296
1555
  */
1297
1556
  export function fullCompletionGuide(site, commandId) {
1298
1557
  const marker = `DONE:${site}`;
@@ -1348,6 +1607,60 @@ export function fullCompletionGuide(site, commandId) {
1348
1607
  // ★nextWork(次の独立作業候補)は必ず書く(conductor_complete の説明と同じ・便 a5306df2 で両方にそろえた)。
1349
1608
  `nextWork(次の独立作業候補)は必ず書いてください(無ければ「なし」と理由)。\n\n`);
1350
1609
  }
1610
+ /**
1611
+ * Codex の職人へ付ける完了案内(便 cfa9eeec・★Claude 向けは shortCompletionLine)。
1612
+ *
1613
+ * 【なぜ分けたか(実測 2026-10-04・codex-cli 0.160.0・便 f8ac8674 / 4c9f5049)】
1614
+ * Claude 用の全文は、Codex にできない2つを求めていた=毎便、無駄な失敗になっていた:
1615
+ * ① ai-kana の MCP(完了の知らせの道具)… Codex にはつながっていない=呼べない。
1616
+ * ② 作業フォルダの外への報告ファイル … 囲いが止める(Read-only file system)。
1617
+ * どちらも頼まない。かわりに【いま既に通っている道】=Codex の notify で届く「最後の返事」に、
1618
+ * 機械で読める囲みを書いてもらう。配り役がそれを読んで、職人が書く物と同じ形の報告ファイルを
1619
+ * 組み立て、いまの完了の道へそのまま流す(intakeCodexReport・受け側の口は1つも変えない)。
1620
+ *
1621
+ * 【★.git への書き込み(実測の違い)】便 f8ac8674 は「止められたら回避せず報告」だけを書いていたので、
1622
+ * Codex は承認を求めずに blocked で終えた。便 4c9f5049 で「正規の手順で承認を1回だけ求めてよい」と
1623
+ * 書いたら、承認されて記録(コミット 04c108f)まで通った。その1〜2文をここに入れておく。
1624
+ *
1625
+ * 【★この文に入れない物(試験で固定)】完了の知らせの道具の名前・報告ファイルの置き場。
1626
+ * どちらも Codex にはできない=書くと毎便失敗して報告が汚れる。
1627
+ */
1628
+ export function codexCompletionGuide(site, commandId) {
1629
+ const marker = `DONE:${site}`;
1630
+ return (
1631
+ // ★確認カードを非エンジニアが読める日本語にする(調査 2987cb8f)。Codex でも確認は同じ形で出る。
1632
+ `作業中にユーザー確認(許可を求めるプロンプト)が出る操作では、その一言説明(description)を` +
1633
+ `英語ではなく日本語で、「プルリクエストを作成する」「テストを実行する」のように` +
1634
+ `「何をするか」が一目で分かる簡潔な形(〜を作成する/実行する/削除する 等)で書いてください` +
1635
+ `(この説明は管制の確認カードにそのまま表示され、非エンジニアのお客さまが読んで許可を判断します)。\n\n` +
1636
+ // ★記録(.git への書き込み)が囲いに止められたときの進み方(実測 f8ac8674 → 4c9f5049)。
1637
+ `記録(git add・git commit)が囲い(サンドボックス)に止められたときは、正規の手順で承認を1回だけ求めてよいです。` +
1638
+ `断られた・求める手段が無かったときは、別のやり方(設定の書き換え・囲いを外す起動・.git を別の場所に作る 等)で` +
1639
+ `通そうとせず、そのまま報告して終えてください。\n\n` +
1640
+ // ★報告は「最後の返事」に書いてもらう(作業フォルダの外へは書けない=囲いが止める)。
1641
+ `作業がすべて完了したら、報告を【この会話の最後の返事】に書いてください` +
1642
+ `(変更ファイル・要点・チェック結果を簡潔に)。` +
1643
+ `★報告を作業フォルダの外のファイルへ書こうとしないでください(囲いが止めます)。\n` +
1644
+ `★最後の返事の先頭に、次の囲みをそのまま付けてください(ここが機械の読み口です。1行に1項目・左の語と「: 」は変えない)。\n` +
1645
+ `1行目: ${CODEX_REPORT_BEGIN}\n` +
1646
+ `2行目: instruction_id: ${commandId}\n` +
1647
+ `3行目: site: ${site}\n` +
1648
+ `4行目: outcome: pushed / no_change / blocked のどれか1つだけを書く` +
1649
+ `(pushed=変更して送った、no_change=変更不要だった、blocked=衝突・要判断・許可待ち等で完了できなかった。★選んだ1語だけを書く)\n` +
1650
+ `5行目: created_at: 書いた日時(例 2026-10-04T18:00:00+09:00)\n` +
1651
+ `6行目: evidence:(この行は「evidence:」だけにして、次の行から「- 」で始めて1行に1つ書く。` +
1652
+ `例「- PR https://github.com/...」「- CI test pass」「- main に入った」。★1件以上必ず書く)\n` +
1653
+ `次の行: pr_number: PR 番号(数字だけ。無ければ なし)\n` +
1654
+ `次の行: next_work: 次の独立作業候補(無ければ「なし」と、なぜ無いかの理由)\n` +
1655
+ `最後の行: ${CODEX_REPORT_END}\n` +
1656
+ `この囲みの下に、いつもどおりの報告本文を書いてください(本文が空だと未完成と見なされます)。\n` +
1657
+ `そして返事のいちばん最後の行に ${marker} とだけ出力してください。\n\n` +
1658
+ // ★道具は呼ばせない(Codex にはつながっていない)。囲みが書けなかったときの倒れ方も書いておく。
1659
+ `★完了の知らせと、管制へ結果(outcome・PR 番号・次の独立作業候補)を入れる作業は、配り役が行います。` +
1660
+ `あなたは上の囲みと ${marker} を書くだけでよく、道具(MCP)を呼ぶ必要はありません。` +
1661
+ `囲みが書けなかったときも、いつもどおり最後の返事に報告を書いて ${marker} で終えてください` +
1662
+ `(配り役がその返事をそのまま報告として受け取ります)。\n\n`);
1663
+ }
1351
1664
  // ───────────────────────────────────────────────────────────────────────────
1352
1665
  // tmux I/O(失敗は握りつぶして null/false。送らない側に倒すため)
1353
1666
  // ───────────────────────────────────────────────────────────────────────────
@@ -2337,6 +2650,8 @@ export function hasSubmittedRecord(cwd, commandId, sinceMs, env = process.env) {
2337
2650
  const text = readFileSync(file, 'utf8');
2338
2651
  if (!text.includes(needle))
2339
2652
  continue;
2653
+ // ★書きかけの行(改行が来ていない末尾)は JSON として読めないので下の catch で飛ばす=
2654
+ // relay は落ちず、次の回に書き終わってから拾う(★最後の1行に改行が無い記録も読み落とさない)。
2340
2655
  for (const line of text.split('\n')) {
2341
2656
  if (!line.includes(needle))
2342
2657
  continue;
@@ -2424,6 +2739,8 @@ export function hasJobStampSince(cwd, commandId, sinceMs, env = process.env) {
2424
2739
  const text = readFileSync(file, 'utf8');
2425
2740
  if (!text.includes(needle))
2426
2741
  continue;
2742
+ // ★書きかけの行(改行が来ていない末尾)は JSON として読めないので下の catch で飛ばす=
2743
+ // relay は落ちず、次の回に書き終わってから拾う(★最後の1行に改行が無い記録も読み落とさない)。
2427
2744
  for (const line of text.split('\n')) {
2428
2745
  if (!hasExactJobStamp(line, commandId))
2429
2746
  continue;
@@ -3775,9 +4092,21 @@ export async function relayTick(cfg, state, hooks) {
3775
4092
  if (kindOf(cfg) === 'codex') {
3776
4093
  const notice = takeCodexNotice(cfg.site);
3777
4094
  if (notice !== null) {
4095
+ // ★最後の返事の囲みを読み、職人が書く物と同じ形の報告ファイルを組み立てる(便 cfa9eeec)。
4096
+ // 書けたら【いまの完了の道】へそのまま流す(受け側の口は1つも変えない)。
4097
+ // 囲みが無い・読めないときは、今までと同じ動き(返事をそのまま報告にして完了)に倒す。
4098
+ const intake = intakeCodexReport(notice.report, {
4099
+ commandId: state.pending.id,
4100
+ site: cfg.site,
4101
+ });
4102
+ log(`site=${cfg.site} id=${state.pending.id} ${intake.note}`);
3778
4103
  const report = notice.report.trim().length > 0 ? notice.report : null;
3779
- if (await patchStatus(cfg, state.pending.id, 'done', report)) {
3780
- log(`site=${cfg.site} id=${state.pending.id} done(Codexの通知${report != null ? '・報告添付' : ''})`);
4104
+ const written = intake.wrote
4105
+ ? await completeWithReport(cfg, state.pending.id, log)
4106
+ : await patchStatus(cfg, state.pending.id, 'done', report);
4107
+ if (written) {
4108
+ log(`site=${cfg.site} id=${state.pending.id} done(Codexの通知` +
4109
+ `${intake.wrote ? '・囲みから報告ファイル' : report != null ? '・報告添付' : ''})`);
3781
4110
  // ★終了の事実を同じ項目で届ける(便 915694ac・1-3)。この配り方では実プロセスを
3782
4111
  // 中継が持っていないので、終わりは【つかめていない】と伝える(終了コードは null)。
3783
4112
  // 届けるのは「中継が完了を確かめた」という事実だけ。★作り話の終了を送らない。
@@ -3790,7 +4119,8 @@ export async function relayTick(cfg, state, hooks) {
3790
4119
  exitSignal: null,
3791
4120
  reportDelivered: report != null || reportDelivery(state.pending.id).delivered,
3792
4121
  processEndObserved: false,
3793
- detail: '配り役が完了の知らせで確かめた(職人の処理は中継が持っていない)',
4122
+ detail: '配り役が完了の知らせで確かめた(職人の処理は中継が持っていない)' +
4123
+ (intake.wrote ? '/最後の返事の囲みから報告ファイルを組み立てた' : ''),
3794
4124
  }, { logLine: log });
3795
4125
  state.pending = null;
3796
4126
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tyhld/conductor",
3
- "version": "0.12.94",
3
+ "version": "0.12.96",
4
4
  "description": "采配くん管制の見守りアプリ(conductor-agent)。各PCで常駐し、中央(devlog-tracker)へ定期的に生存報告(heartbeat)を送る常駐CLI。",
5
5
  "type": "module",
6
6
  "bin": {