@kienntpixon/dieu-phoi 0.3.0 → 0.5.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/hook.cjs CHANGED
@@ -4,7 +4,9 @@
4
4
  * dieu-phoi hook + core helpers.
5
5
  *
6
6
  * This file is copied verbatim to ~/.claude/dieu-phoi/bin/hook.cjs by `dieu-phoi setup`
7
- * and run by Claude Code on PostToolUse(Agent), SubagentStop, SessionStart(compact) and UserPromptSubmit. It must stay
7
+ * and run by Claude Code on PostToolUse(Agent), SubagentStop, SessionStart(compact) and UserPromptSubmit; the dp-* agent
8
+ * files (`dieu-phoi setup` -> ~/.claude/agents) also run it inside those subagents as `--watch` (PostToolUse, context
9
+ * handoff watchdog), `--gate` (PreToolUse(Agent), gen-2 helper rules) and `--gate-release`. It must stay
8
10
  * self-contained: Node built-ins only, no requires of other package files.
9
11
  *
10
12
  * Hook rules: stdout only for the JSON of SessionStart(compact) / UserPromptSubmit, always exit 0,
@@ -13,6 +15,9 @@
13
15
  *
14
16
  * node hook.cjs -> read hook payload from stdin, append event
15
17
  * node hook.cjs --sync -> push unsynced events to the sink (spawned detached)
18
+ * node hook.cjs --watch -> dp subagent PostToolUse: context watchdog (additionalContext JSON or nothing)
19
+ * node hook.cjs --gate -> dp-exec PreToolUse(Agent): allow / deny a gen-2 helper spawn
20
+ * node hook.cjs --gate-release -> dp-exec PostToolUseFailure(Agent): give a failed spawn's helper slot back
16
21
  */
17
22
 
18
23
  const fs = require('fs');
@@ -20,14 +25,14 @@ const path = require('path');
20
25
  const os = require('os');
21
26
  const crypto = require('crypto');
22
27
 
23
- const CORE_VERSION = '0.3.0';
28
+ const CORE_VERSION = '0.5.0';
24
29
  const SCHEMA = 1;
25
30
  const TASK_TYPES = [
26
31
  'search', 'extract', 'mechanical-edit', 'feature-impl', 'debug', 'refactor',
27
32
  'test-write', 'review', 'research-synth', 'content-write', 'plan-design', 'data-analysis',
28
33
  ];
29
34
  const URGENCIES = ['relaxed', 'normal', 'urgent'];
30
- const ROLES = ['exec', 'verify', 'plan'];
35
+ const ROLES = ['exec', 'verify', 'plan', 'helper'];
31
36
  const MODEL_ALIASES = {
32
37
  opus: 'claude-opus-5-5',
33
38
  sonnet: 'claude-sonnet-5-5',
@@ -200,7 +205,7 @@ const UNTAGGED = Object.freeze({
200
205
  task_type: 'untagged', score: null, urgency: null, try: null, task_id: null, role: 'exec',
201
206
  });
202
207
 
203
- /** Parse `[dp:<type>|s<n>|<urgency>|try<n>|task=<id>|role=<r>]` from the first line. */
208
+ /** Parse `[dp:<type>|s<n>|<urgency>|try<n>|task=<id>|role=<r>|parent=<id>]` from the first line. */
204
209
  function parseTag(text) {
205
210
  if (typeof text !== 'string') return null;
206
211
  const first = text.replace(/^/, '').split(/\r?\n/, 1)[0].trim();
@@ -223,6 +228,8 @@ function parseTag(text) {
223
228
  tag.task_id = mm[1];
224
229
  } else if ((mm = /^role=([a-z]+)$/.exec(p)) && ROLES.includes(mm[1])) {
225
230
  tag.role = mm[1];
231
+ } else if ((mm = /^parent=([A-Za-z0-9_-]{1,32})$/.exec(p))) {
232
+ tag.parent_task_id = mm[1]; // core 0.5.0: gen-2 helper -> the dp-exec step that spawned it
226
233
  }
227
234
  }
228
235
  return tag;
@@ -366,23 +373,26 @@ function lastUuidOf(lines) {
366
373
  return null;
367
374
  }
368
375
 
369
- /** Tag from the first user message of the transcript (the original prompt). */
370
- function tagFromTranscript(lines) {
376
+ /** Text of the first user message of the transcript (the original prompt / brief), or null. */
377
+ function firstPromptText(lines) {
371
378
  for (const l of lines) {
372
379
  if (!l || l.type !== 'user' || !l.message) continue;
373
380
  const c = l.message.content;
374
- let text = null;
375
- if (typeof c === 'string') text = c;
376
- else if (Array.isArray(c)) {
381
+ if (typeof c === 'string') return c;
382
+ if (Array.isArray(c)) {
377
383
  const b = c.find((x) => x && x.type === 'text' && typeof x.text === 'string');
378
- if (b) text = b.text;
384
+ if (b) return b.text;
379
385
  }
380
- if (text === null) continue;
381
- return parseTag(text);
382
386
  }
383
387
  return null;
384
388
  }
385
389
 
390
+ /** Tag from the first user message of the transcript (the original prompt). */
391
+ function tagFromTranscript(lines) {
392
+ const text = firstPromptText(lines);
393
+ return text === null ? null : parseTag(text);
394
+ }
395
+
386
396
  /**
387
397
  * Summarize a transcript segment. Usage counted once per requestId (last line wins).
388
398
  * opts.afterUuids: uuids already counted by earlier segments; the segment starts after
@@ -737,10 +747,30 @@ function findSessionTranscript(claudeDir, sessionId) {
737
747
 
738
748
  // ------------------------------------------------------------------ events (append-only)
739
749
 
740
- /** Append one event. events.jsonl is append-only: never rewritten, sync progress lives elsewhere. */
750
+ /**
751
+ * Append one event. events.jsonl is append-only: never rewritten, sync progress lives elsewhere.
752
+ * Core 0.4.0: one O_APPEND write of the whole line (hooks of parallel agents may append at the same time:
753
+ * an O_APPEND write lands as one unit, so lines never interleave). If the file does not
754
+ * end with '\n' (a writer died mid-line), a '\n' is written first so the torn fragment cannot swallow this
755
+ * event — readers skip the fragment as one unparsable line. Withdrawing a task = `dieu-phoi discard`, never an
756
+ * edit of this file.
757
+ */
741
758
  function appendEvent(ctx, ev) {
742
759
  fs.mkdirSync(ctx.paths.data, { recursive: true });
743
- fs.appendFileSync(ctx.paths.events, `${JSON.stringify(ev)}\n`);
760
+ const fd = fs.openSync(ctx.paths.events, 'a+');
761
+ try {
762
+ let lead = '';
763
+ const size = fs.fstatSync(fd).size;
764
+ if (size > 0) {
765
+ const last = Buffer.alloc(1);
766
+ if (fs.readSync(fd, last, 0, 1, size - 1) === 1 && last[0] !== 0x0a) lead = '\n';
767
+ }
768
+ const buf = Buffer.from(`${lead}${JSON.stringify(ev)}\n`, 'utf8');
769
+ let off = 0;
770
+ while (off < buf.length) off += fs.writeSync(fd, buf, off, buf.length - off);
771
+ } finally {
772
+ fs.closeSync(fd);
773
+ }
744
774
  }
745
775
 
746
776
  function readEventsBuffer(ctx) {
@@ -859,7 +889,10 @@ function verdictLayer(ev) {
859
889
  const by = ev.by === 'user' ? 'user' : 'agent';
860
890
  let cause = ev.cause || null;
861
891
  if (by === 'user' && ev.outcome === 'reject' && cause !== 'scope') cause = 'quality';
862
- return { by, outcome: ev.outcome, cause };
892
+ // legacy quota pause recorded as timeout → aborted (same reading as the stats)
893
+ const outcome = by === 'agent' && ev.outcome === 'timeout' && /^(?:quota-pause|usage-limit|auto-quota-pause|auto-usage-limit|paused-for-quota)(?:-|$)/.test(String(ev.reason_code || ''))
894
+ ? 'aborted' : ev.outcome;
895
+ return { by, outcome, cause };
863
896
  }
864
897
 
865
898
  function dispatchInSession(ev, sessionId) {
@@ -893,7 +926,9 @@ function buildSessionState(events, sessionId, opts) {
893
926
  const runs = toPosix(o.runsDir || runsDir());
894
927
  const taskIds = [];
895
928
  const seenTask = new Set();
929
+ const discarded = new Set(events.filter((ev) => ev && ev.kind === 'discard' && ev.task_id).map((ev) => ev.task_id));
896
930
  for (const ev of events) {
931
+ if (ev && discarded.has(ev.task_id)) continue;
897
932
  if (dispatchInSession(ev, sessionId) && typeof ev.task_id === 'string' && ev.task_id && !seenTask.has(ev.task_id)) {
898
933
  seenTask.add(ev.task_id);
899
934
  taskIds.push(ev.task_id);
@@ -908,7 +943,7 @@ function buildSessionState(events, sessionId, opts) {
908
943
  for (const s of p.steps) if (s && s.task_id && !stepOf.has(s.task_id)) stepOf.set(s.task_id, { plan: p, step: s });
909
944
  }
910
945
  for (const id of [...planOfRoot.keys(), ...stepOf.keys()]) {
911
- if (!seenTask.has(id)) { seenTask.add(id); taskIds.push(id); }
946
+ if (!seenTask.has(id) && !discarded.has(id)) { seenTask.add(id); taskIds.push(id); }
912
947
  }
913
948
  const per = new Map(taskIds.map((id) => [id, { dispatches: [], user: [], steers: [], overrides: [], agentIds: new Set() }]));
914
949
  events.forEach((ev, i) => {
@@ -925,7 +960,7 @@ function buildSessionState(events, sessionId, opts) {
925
960
  const agentVerdicts = new Map();
926
961
  for (const ev of events) {
927
962
  if (!ev || typeof ev !== 'object') continue;
928
- if (ev.kind === 'complete' && agentTask.has(ev.agent_id)) completes.set(ev.agent_id, (completes.get(ev.agent_id) || 0) + 1);
963
+ if (ev.kind === 'complete' && agentTask.has(ev.agent_id)) completes.set(ev.agent_id, Math.max(completes.get(ev.agent_id) || 1, evMs(ev) || 1));
929
964
  if (ev.kind === 'verdict') {
930
965
  const v = verdictLayer(ev);
931
966
  if (v.by === 'user') {
@@ -970,7 +1005,9 @@ function buildSessionState(events, sessionId, opts) {
970
1005
  const vs = (agentVerdicts.get(d.agent_id) || []).slice().sort(byTime);
971
1006
  const last = vs[vs.length - 1];
972
1007
  let status;
973
- if (last) status = last.outcome;
1008
+ // aborted (quota pause) then resumed and completed again, no verdict yet → done (waiting for a verdict)
1009
+ if (last && last.outcome === 'aborted' && (completes.get(d.agent_id) || 0) > evMs(last)) status = 'done';
1010
+ else if (last) status = last.outcome;
974
1011
  else status = completes.get(d.agent_id) ? 'done' : 'running';
975
1012
  return {
976
1013
  agent_id: d.agent_id, role: d.role || 'exec', try: d.try === undefined ? null : d.try,
@@ -1209,6 +1246,32 @@ function scoutBriefPath(ctx) {
1209
1246
  return fs.existsSync(f) ? toPosix(f) : null;
1210
1247
  }
1211
1248
 
1249
+ const ORCH_LESSON_RE = /\[(?:orch|orchestrator)\]/i;
1250
+ const ORCH_LESSONS_MAX = 5;
1251
+ const ORCH_LESSON_CHARS = 240;
1252
+ const LESSONS_MAX_BYTES = 256 * 1024;
1253
+
1254
+ /**
1255
+ * Lessons addressed to the orchestrator (core 0.4.0): list lines of <skill_dir>/profile/lessons.md that carry the
1256
+ * tag `[orch]` (legacy `[orchestrator]`), the last ORCH_LESSONS_MAX of them, each cut to ORCH_LESSON_CHARS chars.
1257
+ * The orchestrator never reads lessons.md itself; `start` and the UserPromptSubmit block surface these lines.
1258
+ */
1259
+ function orchLessons(ctx) {
1260
+ const sd = ctx.config && ctx.config.skill_dir;
1261
+ if (!sd) return [];
1262
+ let txt;
1263
+ try {
1264
+ const f = path.join(sd, 'profile', 'lessons.md');
1265
+ if (fs.statSync(f).size > LESSONS_MAX_BYTES) return [];
1266
+ txt = fs.readFileSync(f, 'utf8');
1267
+ } catch (_) { return []; }
1268
+ const out = txt.split(/\r?\n/).map((l) => l.trim())
1269
+ .filter((l) => /^[-*] /.test(l) && ORCH_LESSON_RE.test(l))
1270
+ .map((l) => l.replace(/^[-*] +/, ''))
1271
+ .map((l) => (l.length > ORCH_LESSON_CHARS ? `${l.slice(0, ORCH_LESSON_CHARS - 1)}…` : l));
1272
+ return out.slice(-ORCH_LESSONS_MAX);
1273
+ }
1274
+
1212
1275
  /** Local, network-free health: config, profile, run dir, cached sink status. */
1213
1276
  function quickHealth(ctx, runs) {
1214
1277
  const problems = [];
@@ -1256,7 +1319,15 @@ function stepIds(root, n) {
1256
1319
  function startInfo(ctx) {
1257
1320
  const run = startRun();
1258
1321
  const health = quickHealth(ctx, run.runs);
1259
- return { id: run.id, run_dir: run.run_dir, step_ids: stepIds(run.id, START_STEP_IDS), health, scout_brief: scoutBriefPath(ctx) };
1322
+ return {
1323
+ id: run.id, run_dir: run.run_dir, step_ids: stepIds(run.id, START_STEP_IDS), health, scout_brief: scoutBriefPath(ctx),
1324
+ orch_lessons: orchLessons(ctx),
1325
+ };
1326
+ }
1327
+
1328
+ function orchLessonLines(info) {
1329
+ const ls = (info && info.orch_lessons) || [];
1330
+ return ls.length ? ['bài học cho điều phối viên (profile/lessons.md, [orch]):'].concat(ls.map((l) => ` - ${l}`)) : [];
1260
1331
  }
1261
1332
 
1262
1333
  function formatStartBlock(info) {
@@ -1267,6 +1338,7 @@ function formatStartBlock(info) {
1267
1338
  `step_ids: ${info.step_ids.join(' ')}`,
1268
1339
  `health: ${healthLine(info.health)}`,
1269
1340
  `scout_brief: ${info.scout_brief || '(không thấy — dùng references/brief-template.md mục E)'}`,
1341
+ ...orchLessonLines(info),
1270
1342
  'Dùng khối này cho yêu cầu MỚI ở cổng 1 (không dùng nếu đang giữa một lần điều phối). Không chạy `dieu-phoi doctor` / `new-id` / mkdir: lượt đầu tiên là Agent scout + câu hỏi cổng 1 cho người dùng.',
1271
1343
  ].join('\n');
1272
1344
  }
@@ -1336,7 +1408,10 @@ function buildDispatch(p, ctx) {
1336
1408
  orch_ctx_baseline: orch.ctx_baseline,
1337
1409
  orch_compactions: orch.compactions,
1338
1410
  orch_cum_usd: orch.cum_usd,
1339
- }, commonFields(ctx));
1411
+ }, tag.parent_task_id ? { parent_task_id: tag.parent_task_id } : {},
1412
+ // core 0.5.0: an Agent call made inside a subagent (gen-2) carries the caller's agent_id
1413
+ typeof p.agent_id === 'string' && p.agent_id ? { parent_agent_id: p.agent_id } : {},
1414
+ commonFields(ctx));
1340
1415
  }
1341
1416
 
1342
1417
  function handlePostToolUse(p, ctx) {
@@ -1373,6 +1448,124 @@ function countSkippedInternal(ctx) {
1373
1448
  } catch (_) { /* best effort */ }
1374
1449
  }
1375
1450
 
1451
+ // ------------------------------------------------------------------ core 0.5.0: hand-over files
1452
+ //
1453
+ // Claude Code denies Write inside any subagent when the basename matches BLOCKED_RESULT_NAME_RE ("Subagents should
1454
+ // return findings as text, not write report files" — hardcoded, before permissions). So exec / verify / plan agents
1455
+ // write their full result to dp-out-<task>-<role>-try<n>.md and return only path + STATUS + a few lines. Run dirs
1456
+ // from before 0.5.0 hold report-<task>-try<n>.md / verify-<task>-try<n>.md: still recognised as results.
1457
+
1458
+ const RESULT_ROLES = ['exec', 'verify', 'plan'];
1459
+ const BLOCKED_RESULT_NAME_RE = /^(REPORT|SUMMARY|FINDINGS|ANALYSIS).*\.md$/i;
1460
+ const INLINE_MAX_BYTES = 256 * 1024;
1461
+
1462
+ function escRe(s) {
1463
+ return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1464
+ }
1465
+
1466
+ /** Canonical hand-over file name of one attempt: dp-out-<task>-<role>-try<n>.md. */
1467
+ function resultFileName(taskId, role, tryN) {
1468
+ return `dp-out-${safeName(taskId)}-${role}-try${Number(tryN) || 1}.md`;
1469
+ }
1470
+
1471
+ /**
1472
+ * Result files already in runDir for (task, role, try), sorted: dp-out-* (with -2 / -spot / .inline suffixes) and the
1473
+ * legacy report-<task>-try<n>*.md (exec) / verify-<task>-try<n>*.md (verify). Never throws.
1474
+ */
1475
+ function findResultFiles(runDir, taskId, role, tryN) {
1476
+ let names;
1477
+ try { names = fs.readdirSync(runDir); } catch (_) { return []; }
1478
+ const t = escRe(safeName(taskId));
1479
+ const n = Number(tryN) || 1;
1480
+ const pats = [new RegExp(`^dp-out-${t}-${escRe(role)}-try${n}(?:[-.][A-Za-z0-9.-]*)?\\.md$`)];
1481
+ if (role === 'exec') pats.push(new RegExp(`^report-${t}-try${n}(?:-[A-Za-z0-9-]*)?\\.md$`));
1482
+ if (role === 'verify') pats.push(new RegExp(`^verify-${t}-try${n}(?:-[A-Za-z0-9-]*)?\\.md$`));
1483
+ return names.filter((x) => pats.some((re) => re.test(x))).sort().map((x) => path.join(runDir, x));
1484
+ }
1485
+
1486
+ function insideRunsDir(d) {
1487
+ const r = path.relative(runsDir(), d);
1488
+ return !!r && !r.startsWith('..') && !path.isAbsolute(r);
1489
+ }
1490
+
1491
+ function expandHome(s) {
1492
+ return s === '~' || s.startsWith('~/') ? path.join(os.homedir(), s.slice(1)) : s;
1493
+ }
1494
+
1495
+ /**
1496
+ * Run dir an agent's brief points at (never outside runsDir()): the folder of a dp-out-/handoff- path named in the
1497
+ * brief, else a `…/runs/<id>` path (the one named after the task first), else runsDir()/<task_id>.
1498
+ */
1499
+ function resultRunDir(prompt, taskId) {
1500
+ const text = String(prompt || '').replace(/\\/g, '/');
1501
+ const PATH = "(?:[A-Za-z]:)?~?[^\\s`'\"<>()|*?]*";
1502
+ for (const m of text.matchAll(new RegExp(`(${PATH}/)(?:dp-out-|handoff-)[A-Za-z0-9_.-]*\\.md`, 'g'))) {
1503
+ const d = path.resolve(expandHome(m[1]));
1504
+ if (insideRunsDir(d)) return d;
1505
+ }
1506
+ const dirs = [];
1507
+ for (const m of text.matchAll(new RegExp(`(${PATH}/runs/[A-Za-z0-9_-]{1,64})(?![A-Za-z0-9_-])`, 'g'))) {
1508
+ const d = path.resolve(expandHome(m[1]));
1509
+ if (insideRunsDir(d) && path.dirname(d) === path.resolve(runsDir())) dirs.push(d);
1510
+ }
1511
+ const own = dirs.find((d) => path.basename(d) === safeName(taskId));
1512
+ if (own || dirs.length) return own || dirs[0];
1513
+ return path.join(runsDir(), safeName(taskId));
1514
+ }
1515
+
1516
+ /** Final text the agent returned: the payload's last_assistant_message, else the last assistant text in the transcript. */
1517
+ function finalAssistantText(lines, given) {
1518
+ if (typeof given === 'string' && given.trim()) return given;
1519
+ for (let i = lines.length - 1; i >= 0; i--) {
1520
+ const l = lines[i];
1521
+ if (!l || l.type !== 'assistant' || !l.message) continue;
1522
+ if (l.isApiErrorMessage === true) return null;
1523
+ const c = l.message.content;
1524
+ const text = typeof c === 'string' ? c
1525
+ : (Array.isArray(c) ? c.filter((x) => x && x.type === 'text' && typeof x.text === 'string').map((x) => x.text).join('\n') : '');
1526
+ if (text.trim()) return text;
1527
+ }
1528
+ return null;
1529
+ }
1530
+
1531
+ /** Whether the returned text names an existing dp-out-/handoff-/report-/verify-*.md under runsDir() (written under another name). */
1532
+ function namesExistingRunFile(text) {
1533
+ const t = String(text).replace(/\\/g, '/');
1534
+ for (const m of t.matchAll(/((?:[A-Za-z]:)?~?[^\s`'"<>()|*?]*\/(?:dp-out|handoff|report|verify)-[A-Za-z0-9_.-]+\.md)/g)) {
1535
+ const f = path.resolve(expandHome(m[1]));
1536
+ if (insideRunsDir(f) && fs.existsSync(f)) return true;
1537
+ }
1538
+ return false;
1539
+ }
1540
+
1541
+ /**
1542
+ * SubagentStop fallback: an exec / verify / plan agent that came back without its hand-over file (Write denied, or it
1543
+ * answered inline) gets its returned text saved as <run>/dp-out-<task>-<role>-try<n>.inline.md so the next step can
1544
+ * still read a file. Skipped when a result file for that attempt exists (dp-out or legacy), when the text names an
1545
+ * existing run-dir .md file, and for handoffs (the handoff file is the hand-over). Returns the path written or null.
1546
+ */
1547
+ function persistInlineResult(lines, tag, opts) {
1548
+ const o = opts || {};
1549
+ if (!tag || !RESULT_ROLES.includes(tag.role) || !tag.task_id) return null;
1550
+ const text = finalAssistantText(lines, o.finalText);
1551
+ if (!text) return null;
1552
+ if (text.trimStart().startsWith(HANDOFF_MARKER)) return null;
1553
+ const dir = resultRunDir(firstPromptText(lines), tag.task_id);
1554
+ if (findResultFiles(dir, tag.task_id, tag.role, tag.try).length) return null;
1555
+ if (namesExistingRunFile(text)) return null;
1556
+ const file = path.join(dir, resultFileName(tag.task_id, tag.role, tag.try).replace(/\.md$/, '.inline.md'));
1557
+ let body = text.replace(/\r\n/g, '\n');
1558
+ if (Buffer.byteLength(body) > INLINE_MAX_BYTES) body = `${Buffer.from(body).subarray(0, INLINE_MAX_BYTES).toString('utf8')}\n\n[… cắt bớt]`;
1559
+ fs.mkdirSync(dir, { recursive: true });
1560
+ try {
1561
+ fs.writeFileSync(file, `<!-- dieu-phoi: agent ${tag.role} không ghi file kết quả; hook SubagentStop lưu nguyên văn phần trả về -->\n\n${body}\n`, { flag: 'wx' });
1562
+ } catch (e) {
1563
+ if (e && e.code === 'EEXIST') return null;
1564
+ throw e;
1565
+ }
1566
+ return file;
1567
+ }
1568
+
1376
1569
  function handleSubagentStop(p, ctx) {
1377
1570
  const agentId = typeof p.agent_id === 'string' && p.agent_id ? p.agent_id : null;
1378
1571
  if (!agentId) return null;
@@ -1425,9 +1618,386 @@ function handleSubagentStop(p, ctx) {
1425
1618
  });
1426
1619
  }
1427
1620
  appendEvent(ctx, ev);
1621
+ if (exists && tag) {
1622
+ try { persistInlineResult(lines, tag, { finalText: p.last_assistant_message }); } catch (_) { /* best effort */ }
1623
+ }
1624
+ // Core 0.4.0: a segment that ended on a usage-limit / quota pause gets an automatic agent verdict
1625
+ // `aborted` (never a fail, never counted toward escalation); the real verdict comes after the resume.
1626
+ const why = exists ? abortReasonOf(lines) : null;
1627
+ if (why) {
1628
+ appendEvent(ctx, Object.assign({
1629
+ schema: SCHEMA, kind: 'verdict', ts: nowIso(), agent_id: agentId, task_id: t.task_id, by: 'agent', outcome: 'aborted',
1630
+ verify: 'none', reason_code: `auto-${why}`, cause: null, round: null,
1631
+ }, commonFields(ctx)));
1632
+ }
1428
1633
  return ev;
1429
1634
  }
1430
1635
 
1636
+ // Exact marker an agent puts on the FIRST line of its final report when it stops at a safe point because of a quota
1637
+ // / usage limit (brief template A, SKILL.md). Nothing looser: "STATUS: partial" + "quota" can be a real failure
1638
+ // ("Drive storage quota exceeded", a half-built rate limiter).
1639
+ const QUOTA_PAUSE_MARKER = 'STATUS: partial — quota-pause';
1640
+ // Core 0.5.0: exact marker of an agent that stopped on the context watchdog and wrote handoff.md (continuation, not a fail).
1641
+ const HANDOFF_MARKER = 'STATUS: partial — handoff';
1642
+ // Claude Code's own usage-limit stop text on its synthetic API-error line ("You've hit your session limit · resets 11am").
1643
+ const CC_LIMIT_TEXT_RE = /^You['’]ve hit your (?:[\w-]+ )?limit\b/;
1644
+
1645
+ /**
1646
+ * Why the agent's last segment stopped on a usage limit, or null. Reads only the LAST assistant line:
1647
+ * - Claude Code's synthetic API-error line (`model:"<synthetic>"` + `isApiErrorMessage:true`) whose `error` is
1648
+ * "rate_limit", or HTTP 429, or whose text starts "You've hit your … limit" → "usage-limit";
1649
+ * - the agent's final report whose first line starts with the exact marker QUOTA_PAUSE_MARKER → "quota-pause".
1650
+ * Only a short label is returned; no text is stored.
1651
+ */
1652
+ function abortReasonOf(lines) {
1653
+ let last = null;
1654
+ for (let i = lines.length - 1; i >= 0; i--) {
1655
+ const l = lines[i];
1656
+ if (l && l.type === 'assistant' && l.message) { last = l; break; }
1657
+ }
1658
+ if (!last) return null;
1659
+ const content = Array.isArray(last.message.content) ? last.message.content : [];
1660
+ const text = content.filter((c) => c && c.type === 'text' && typeof c.text === 'string').map((c) => c.text).join('\n').slice(0, 2000);
1661
+ if (last.isApiErrorMessage === true) {
1662
+ if (last.message.model !== '<synthetic>') return null;
1663
+ if (last.error === 'rate_limit' || last.apiErrorStatus === 429 || CC_LIMIT_TEXT_RE.test(text.trim())) return 'usage-limit';
1664
+ return null;
1665
+ }
1666
+ const first = text.trimStart().split('\n')[0].trim();
1667
+ if (first.startsWith(QUOTA_PAUSE_MARKER)) return 'quota-pause';
1668
+ if (first.startsWith(HANDOFF_MARKER)) return 'handoff';
1669
+ return null;
1670
+ }
1671
+
1672
+ // ------------------------------------------------------------------ core 0.5.0: context watchdog + gen-2 gate
1673
+ //
1674
+ // Both run only from the hooks of the dp-* agent files (~/.claude/agents, installed by `dieu-phoi setup`), so a session
1675
+ // that never uses dieu-phoi never runs them. They still check that the subagent's first prompt carries a `[dp:` tag
1676
+ // (state cached per agent in data/watch/<agent_id>.json) and do nothing on the main thread (no agent_id).
1677
+
1678
+ const HANDOFF_THRESHOLDS = Object.freeze([150000, 180000]);
1679
+ const WATCH_HEAD_BYTES = 64 * 1024;
1680
+ const WATCH_HEAD_MAX = 2 << 20;
1681
+ const WATCH_TAIL_BYTES = 256 * 1024;
1682
+ const WATCH_TAIL_MAX = 8 << 20;
1683
+ const WATCH_TTL_MS = 14 * 24 * 3600 * 1000;
1684
+ const GATE_MAX_CHILDREN = 4;
1685
+ const HELPER_AGENT_TYPE = 'dp-helper';
1686
+ const HELPER_MODELS = ['haiku', 'sonnet'];
1687
+
1688
+ /** The subagent the hook fired in: payload agent_id, else the `agent-<id>.jsonl` transcript name; null on the main thread. */
1689
+ function watchAgentId(p) {
1690
+ if (typeof p.agent_id === 'string' && p.agent_id) return p.agent_id;
1691
+ for (const k of ['agent_transcript_path', 'transcript_path']) {
1692
+ const m = typeof p[k] === 'string' ? /agent-([A-Za-z0-9_-]{6,64})\.jsonl$/.exec(p[k]) : null;
1693
+ if (m) return m[1];
1694
+ }
1695
+ return null;
1696
+ }
1697
+
1698
+ /**
1699
+ * The subagent's own transcript. Claude Code layout: <project>/<session_id>.jsonl (main) and
1700
+ * <project>/<session_id>/subagents/agent-<agent_id>.jsonl. Uses a payload path when it already names the agent's file.
1701
+ */
1702
+ function subagentTranscriptPath(p, agentId) {
1703
+ const name = `agent-${agentId}.jsonl`;
1704
+ for (const k of ['agent_transcript_path', 'transcript_path']) {
1705
+ if (typeof p[k] === 'string' && p[k] && path.basename(p[k]) === name) return p[k];
1706
+ }
1707
+ const tp = p.transcript_path;
1708
+ if (typeof tp !== 'string' || !tp) return null;
1709
+ const guesses = [
1710
+ path.join(path.dirname(tp), path.basename(tp, '.jsonl'), 'subagents', name),
1711
+ path.join(path.dirname(tp), name), // transcript_path already inside a subagents dir (nested spawn)
1712
+ ];
1713
+ for (const g of guesses) if (fs.existsSync(g)) return g;
1714
+ return null;
1715
+ }
1716
+
1717
+ function readSlice(fd, start, len) {
1718
+ const buf = Buffer.allocUnsafe(len);
1719
+ const n = fs.readSync(fd, buf, 0, len, start);
1720
+ return buf.subarray(0, Math.max(0, n));
1721
+ }
1722
+
1723
+ /**
1724
+ * Tag of the subagent's first prompt, read from the head of its transcript only.
1725
+ * Returns { found: false } while no user line is on disk yet (transcript written async), else { found: true, tag|null }.
1726
+ */
1727
+ function readHeadTag(file) {
1728
+ let fd;
1729
+ try { fd = fs.openSync(file, 'r'); } catch (_) { return { found: false, tag: null }; }
1730
+ try {
1731
+ const size = fs.fstatSync(fd).size;
1732
+ for (let want = WATCH_HEAD_BYTES; ; want *= 2) {
1733
+ const len = Math.min(want, size, WATCH_HEAD_MAX);
1734
+ const text = readSlice(fd, 0, len).toString('utf8');
1735
+ const complete = len === size ? text : text.slice(0, text.lastIndexOf('\n') + 1);
1736
+ const lines = parseJsonl(complete);
1737
+ if (lines.some((l) => l && l.type === 'user' && l.message)) return { found: true, tag: tagFromTranscript(lines), prompt: firstPromptText(lines) };
1738
+ if (len >= size || len >= WATCH_HEAD_MAX) return { found: false, tag: null };
1739
+ }
1740
+ } finally {
1741
+ try { fs.closeSync(fd); } catch (_) { /* ignore */ }
1742
+ }
1743
+ }
1744
+
1745
+ /** Context of the subagent's latest API request = input + cache_read + cache_creation (tail of the transcript only). */
1746
+ function lastContextTokens(file) {
1747
+ let fd;
1748
+ try { fd = fs.openSync(file, 'r'); } catch (_) { return null; }
1749
+ try {
1750
+ const size = fs.fstatSync(fd).size;
1751
+ for (let win = WATCH_TAIL_BYTES; ; win *= 2) {
1752
+ const start = Math.max(0, size - win);
1753
+ const buf = readSlice(fd, start, size - start);
1754
+ let end = buf.length;
1755
+ while (end > 0) {
1756
+ const nl = buf.lastIndexOf(0x0a, end - 1);
1757
+ const line = buf.subarray(nl + 1, end);
1758
+ end = nl < 0 ? 0 : nl;
1759
+ if (nl < 0 && start > 0) break; // first partial line of the window
1760
+ if (line.indexOf(USAGE_NEEDLE) < 0) continue;
1761
+ let l;
1762
+ try { l = JSON.parse(line.toString('utf8')); } catch (_) { continue; }
1763
+ const msg = l && l.type === 'assistant' ? l.message : null;
1764
+ if (!msg || !msg.usage || msg.model === '<synthetic>') continue;
1765
+ const u = msg.usage;
1766
+ return (Number(u.input_tokens) || 0) + (Number(u.cache_read_input_tokens) || 0) + (Number(u.cache_creation_input_tokens) || 0);
1767
+ }
1768
+ if (start === 0 || win >= WATCH_TAIL_MAX) return null;
1769
+ }
1770
+ } finally {
1771
+ try { fs.closeSync(fd); } catch (_) { /* ignore */ }
1772
+ }
1773
+ }
1774
+
1775
+ function watchFile(ctx, agentId) {
1776
+ return path.join(ctx.paths.data, 'watch', `${safeName(agentId)}.json`);
1777
+ }
1778
+
1779
+ function pruneWatch(dir) {
1780
+ let names = [];
1781
+ try { names = fs.readdirSync(dir); } catch (_) { return; }
1782
+ const cutoff = Date.now() - WATCH_TTL_MS;
1783
+ for (const n of names) {
1784
+ const f = path.join(dir, n);
1785
+ try { if (fs.statSync(f).mtimeMs < cutoff) fs.unlinkSync(f); } catch (_) { /* ignore */ }
1786
+ }
1787
+ }
1788
+
1789
+ /**
1790
+ * Per-agent watch state { dp, task_id, role, parent_task_id, fired, t }, created on the agent's first hook from the
1791
+ * head of its transcript with an exclusive create (two hooks racing to create it never clobber a later update). Only
1792
+ * --watch updates it; the gate keeps its own slot files (gateDir). Returns { st, file, tpath }; st is null while the
1793
+ * first prompt is not on disk yet.
1794
+ */
1795
+ /** Name of the run dir a brief points at (dp-out-/handoff- folder or …/runs/<id>), else null. */
1796
+ function runIdFromPrompt(prompt) {
1797
+ const d = resultRunDir(prompt, '');
1798
+ return path.dirname(d) === path.resolve(runsDir()) ? path.basename(d) : null;
1799
+ }
1800
+
1801
+ function watchState(p, ctx, agentId) {
1802
+ const file = watchFile(ctx, agentId);
1803
+ const tpath = subagentTranscriptPath(p, agentId);
1804
+ let st = readJsonFile(file, null);
1805
+ if (!st || typeof st !== 'object') {
1806
+ st = null;
1807
+ if (tpath) {
1808
+ const h = readHeadTag(tpath);
1809
+ if (h.found) {
1810
+ st = h.tag
1811
+ ? { dp: true, task_id: h.tag.task_id, run_id: h.tag.task_id ? null : runIdFromPrompt(h.prompt), role: h.tag.role, parent_task_id: h.tag.parent_task_id || null, fired: 0, t: Date.now() }
1812
+ : { dp: false, t: Date.now() };
1813
+ fs.mkdirSync(path.dirname(file), { recursive: true });
1814
+ pruneWatch(path.dirname(file));
1815
+ try {
1816
+ fs.writeFileSync(file, `${JSON.stringify(st)}\n`, { flag: 'wx' });
1817
+ } catch (_) {
1818
+ st = readJsonFile(file, st); // another hook created it first: its copy wins
1819
+ }
1820
+ }
1821
+ }
1822
+ }
1823
+ return { st, file, tpath };
1824
+ }
1825
+
1826
+ function handoffMessage(st, tokens, level, thresholds) {
1827
+ if (st.role && st.role !== 'exec') {
1828
+ // verify / scout / helper: no continuation agent exists, so no handoff file — wrap up with what is there.
1829
+ const what = st.role === 'verify' ? 'verdict (dòng STATUS + các phát hiện)' : 'bản tóm tắt';
1830
+ const last = level >= thresholds.length ? ' NHẮC LẦN CUỐI.' : '';
1831
+ return `dieu-phoi: ngữ cảnh của bạn đã ~${Math.round(tokens / 1000)}k token.${last} Dừng đọc/chạy thêm: trả ${what} NGAY với những gì đã có, ghi rõ phần nào chưa kiểm được. Không ghi file handoff — chỉ agent exec mới handoff.`;
1832
+ }
1833
+ const tid = safeName(st.task_id || st.run_id || 'task');
1834
+ const file = `handoff-${tid}.md`;
1835
+ const where = `\`${file}\` trong run dir của brief (đã có thì thêm -2, -3; brief không có run dir → ${toPosix(path.join(runsDir(), tid))}/)`;
1836
+ const k = Math.round(tokens / 1000);
1837
+ if (level >= thresholds.length) {
1838
+ return `dieu-phoi: NHẮC LẦN CUỐI — ngữ cảnh của bạn đã ~${k}k token. Không làm thêm việc gì: ghi ${where} ngay (nếu chưa ghi) rồi kết thúc, dòng đầu báo cáo đúng \`${HANDOFF_MARKER}\` kèm đường dẫn file handoff.`;
1839
+ }
1840
+ return `dieu-phoi: ngữ cảnh của bạn đã ~${k}k token (ngưỡng ${Math.round(thresholds[level - 1] / 1000)}k). Dừng nhận việc mới: làm nốt thao tác đang dở tới điểm an toàn (không để file sửa dở, test đang chạy thì chờ kết quả), rồi ghi ${where} gồm 5 mục: Đã xong / Đang dở / Bước tiếp theo / File đã động / Bẫy & ghi chú (lệnh test, đường dẫn, quyết định đã chốt). Sau đó kết thúc: dòng đầu báo cáo đúng \`${HANDOFF_MARKER}\` kèm đường dẫn file handoff. Điều phối viên sẽ giao một agent tiếp nối cùng tier đọc file đó — đây không phải thất bại.`;
1841
+ }
1842
+
1843
+ /** `--watch` (PostToolUse inside a dp-* subagent): additionalContext once per threshold crossing, else nothing. */
1844
+ function handleWatch(p, ctx, o) {
1845
+ const agentId = watchAgentId(p);
1846
+ if (!agentId) return null;
1847
+ const { st, file, tpath } = watchState(p, ctx, agentId);
1848
+ if (!st || !st.dp || !tpath) return null;
1849
+ // Gate slots: a helper's first hook proves its spawn started; so does the exec's own successful Agent call.
1850
+ if (st.role === 'helper' && st.parent_task_id && st.task_id) gateConfirm(ctx, st.parent_task_id, st.task_id);
1851
+ if ((p.tool_name === 'Agent' || p.tool_name === 'Task') && st.role === 'exec') {
1852
+ const ct = parseTag(p.tool_input && p.tool_input.prompt);
1853
+ if (ct && ct.task_id) gateConfirm(ctx, st.task_id || agentId, ct.task_id);
1854
+ }
1855
+ const thresholds = Array.isArray(o && o.thresholds) && o.thresholds.length ? o.thresholds : HANDOFF_THRESHOLDS;
1856
+ const tokens = lastContextTokens(tpath);
1857
+ if (tokens === null) return null;
1858
+ let level = 0;
1859
+ while (level < thresholds.length && tokens >= thresholds[level]) level++;
1860
+ if (level <= (Number(st.fired) || 0)) return null;
1861
+ st.fired = level;
1862
+ st.ctx_tokens = tokens;
1863
+ st.t = Date.now();
1864
+ writeFileAtomic(file, `${JSON.stringify(st)}\n`);
1865
+ return {
1866
+ level,
1867
+ ctx_tokens: tokens,
1868
+ stdout: JSON.stringify({
1869
+ hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: handoffMessage(st, tokens, level, thresholds) },
1870
+ }),
1871
+ };
1872
+ }
1873
+
1874
+ /** A reservation that neither the parent's PostToolUse(Agent) nor the helper's own first hook confirmed in this long never started. */
1875
+ const GATE_PENDING_MS = 10 * 60 * 1000;
1876
+
1877
+ /** Per-step gate dir: one `slot-<n>.json` file per reserved helper (n = 1..GATE_MAX_CHILDREN), keyed by the exec's task. */
1878
+ function gateDir(ctx, key) {
1879
+ return path.join(ctx.paths.data, 'gate', safeName(key));
1880
+ }
1881
+
1882
+ function pruneGate(root) {
1883
+ let names = [];
1884
+ try { names = fs.readdirSync(root); } catch (_) { return; }
1885
+ const cutoff = Date.now() - WATCH_TTL_MS;
1886
+ for (const n of names) {
1887
+ const d = path.join(root, n);
1888
+ try { if (fs.statSync(d).mtimeMs < cutoff) fs.rmSync(d, { recursive: true, force: true }); } catch (_) { /* ignore */ }
1889
+ }
1890
+ }
1891
+
1892
+ /** Slots in use: a confirmed one always counts; an unconfirmed one counts until GATE_PENDING_MS, then it is released. */
1893
+ function gateSlots(dir) {
1894
+ const out = [];
1895
+ for (let i = 1; i <= GATE_MAX_CHILDREN; i++) {
1896
+ const f = path.join(dir, `slot-${i}.json`);
1897
+ const r = readJsonFile(f, null);
1898
+ if (!r) continue;
1899
+ if (!r.started && Date.now() - (Number(r.t) || 0) > GATE_PENDING_MS) {
1900
+ try { fs.unlinkSync(f); } catch (_) { /* someone else released it */ }
1901
+ continue;
1902
+ }
1903
+ out.push(Object.assign({ file: f, slot: i }, r));
1904
+ }
1905
+ return out;
1906
+ }
1907
+
1908
+ /** Reserve a free slot with an exclusive create (atomic on Windows and POSIX): parallel calls never share a slot. */
1909
+ function gateReserve(dir, child) {
1910
+ fs.mkdirSync(dir, { recursive: true });
1911
+ gateSlots(dir); // drop stale reservations first
1912
+ for (let i = 1; i <= GATE_MAX_CHILDREN; i++) {
1913
+ const f = path.join(dir, `slot-${i}.json`);
1914
+ try {
1915
+ fs.writeFileSync(f, `${JSON.stringify({ child, t: Date.now(), started: false })}\n`, { flag: 'wx' });
1916
+ return i;
1917
+ } catch (_) { /* taken */ }
1918
+ }
1919
+ return 0;
1920
+ }
1921
+
1922
+ /** Mark the reservation of `child` as a real spawn. Returns true when its slot was found. */
1923
+ function gateConfirm(ctx, key, child) {
1924
+ if (!key || !child) return false;
1925
+ const dir = gateDir(ctx, key);
1926
+ if (!fs.existsSync(dir)) return false;
1927
+ for (const r of gateSlots(dir)) {
1928
+ if (r.child !== child) continue;
1929
+ if (r.started) return true;
1930
+ try {
1931
+ writeFileAtomic(r.file, `${JSON.stringify({ child, t: r.t, started: true, started_t: Date.now() })}\n`);
1932
+ return true;
1933
+ } catch (_) { return false; }
1934
+ }
1935
+ return false;
1936
+ }
1937
+
1938
+ /**
1939
+ * `--gate` (PreToolUse(Agent) inside dp-exec): a dp-tagged exec may run at most GATE_MAX_CHILDREN gen-2 children per
1940
+ * step, each `dp-helper`, model haiku|sonnet set explicitly, prompt tagged `role=helper|parent=<own task>|task=<new id>`.
1941
+ * Anything else is denied with the reason; an allowed spawn prints nothing (normal permission flow). The budget is a set
1942
+ * of slot files (gateReserve, exclusive create), separate from the watch state. A slot is confirmed by the parent's
1943
+ * PostToolUse(Agent) or the helper's own first --watch, released by --gate-release (PostToolUseFailure) or, if never
1944
+ * confirmed, after GATE_PENDING_MS — so only spawns that really start use the budget.
1945
+ */
1946
+ function handleGate(p, ctx) {
1947
+ if (p.tool_name !== 'Agent' && p.tool_name !== 'Task') return null;
1948
+ const agentId = watchAgentId(p);
1949
+ if (!agentId) return null;
1950
+ const { st } = watchState(p, ctx, agentId);
1951
+ if (!st || !st.dp) return null;
1952
+ const ti = p.tool_input && typeof p.tool_input === 'object' ? p.tool_input : {};
1953
+ const why = [];
1954
+ if (st.role && st.role !== 'exec') why.push(`chỉ agent role=exec được giao helper (bạn là role=${st.role})`);
1955
+ if (ti.subagent_type !== HELPER_AGENT_TYPE) why.push(`subagent_type phải là ${HELPER_AGENT_TYPE} (đang là ${ti.subagent_type || 'mặc định'})`);
1956
+ if (!HELPER_MODELS.includes(shortModelName(ti.model))) why.push(`model phải đặt rõ haiku hoặc sonnet (đang là ${ti.model || 'không đặt = kế thừa'})`);
1957
+ const tag = parseTag(ti.prompt);
1958
+ const parentHint = st.task_id || '<task của bạn>';
1959
+ if (!tag || tag.role !== 'helper' || !tag.task_id || !tag.parent_task_id) {
1960
+ why.push(`dòng đầu prompt phải là [dp:<type>|s<n>|<urgency>|try1|task=<id mới từ dieu-phoi new-id>|role=helper|parent=${parentHint}]`);
1961
+ } else if (st.task_id && tag.parent_task_id !== st.task_id) {
1962
+ why.push(`parent= phải là task của bạn (${st.task_id})`);
1963
+ }
1964
+ const deny = (list) => ({
1965
+ denied: true,
1966
+ stdout: JSON.stringify({
1967
+ hookSpecificOutput: {
1968
+ hookEventName: 'PreToolUse',
1969
+ permissionDecision: 'deny',
1970
+ permissionDecisionReason: `dieu-phoi gen-2: ${list.join('; ')}.`,
1971
+ },
1972
+ }),
1973
+ });
1974
+ if (why.length) return deny(why);
1975
+ const root = path.join(ctx.paths.data, 'gate');
1976
+ fs.mkdirSync(root, { recursive: true });
1977
+ pruneGate(root);
1978
+ const dir = gateDir(ctx, st.task_id || agentId);
1979
+ const slot = gateReserve(dir, tag.task_id);
1980
+ if (!slot) return deny([`đã giao đủ ${GATE_MAX_CHILDREN} helper cho bước này — tự làm phần còn lại`]);
1981
+ return { denied: false, slot, children: gateSlots(dir).length };
1982
+ }
1983
+
1984
+ /** `--gate-release` (PostToolUseFailure(Agent) inside dp-exec): the spawn failed, give its unconfirmed slot back. */
1985
+ function handleGateRelease(p, ctx) {
1986
+ if (p.tool_name !== 'Agent' && p.tool_name !== 'Task') return null;
1987
+ const agentId = watchAgentId(p);
1988
+ if (!agentId) return null;
1989
+ const { st } = watchState(p, ctx, agentId);
1990
+ if (!st || !st.dp) return null;
1991
+ const tag = parseTag(p.tool_input && p.tool_input.prompt);
1992
+ if (!tag || !tag.task_id) return null;
1993
+ for (const r of gateSlots(gateDir(ctx, st.task_id || agentId))) {
1994
+ if (r.child !== tag.task_id || r.started) continue;
1995
+ try { fs.unlinkSync(r.file); } catch (_) { /* already gone */ }
1996
+ return { released: r.slot };
1997
+ }
1998
+ return null;
1999
+ }
2000
+
1431
2001
  function maybeSpawnSync(ctx) {
1432
2002
  if (!ctx.config.sink_url || !ctx.config.sink_token) return false;
1433
2003
  if (process.env.DIEU_PHOI_NO_SPAWN === '1') return false;
@@ -1479,6 +2049,9 @@ function runHook(opts) {
1479
2049
  const ctx = makeCtx(home);
1480
2050
  let ev = null;
1481
2051
  const name = p.hook_event_name;
2052
+ if (o.mode === 'watch') return handleWatch(p, ctx, o); // { stdout } or null
2053
+ if (o.mode === 'gate') return handleGate(p, ctx, o); // { stdout } or null
2054
+ if (o.mode === 'gate-release') return handleGateRelease(p, ctx); // { released } or null, prints nothing
1482
2055
  if (name === 'SessionStart') return handleSessionStart(p, ctx); // { stdout } or null, never an event
1483
2056
  if (name === 'UserPromptSubmit') return handleUserPromptSubmit(p, ctx); // { stdout } or null
1484
2057
  if (name === 'SubagentStop') ev = handleSubagentStop(p, ctx);
@@ -1632,13 +2205,15 @@ async function runSync(ctx, opts) {
1632
2205
  // ------------------------------------------------------------------ exports / main
1633
2206
 
1634
2207
  module.exports = {
1635
- runsDir, loadPlans, isDieuPhoiPrompt, foldPrompt, sinkStatusCached, sinkUrlKey, SINK_CACHE_TTL_MS, scoutBriefPath, quickHealth, startRun, startInfo, stepIds, START_STEP_IDS, healthLine, formatStartBlock, handleUserPromptSubmit, START_PREFIX, buildSessionState, formatSessionState, stateCounts, compactSystemMessage, handleSessionStart, dispatchInSession, STATE_TEXT_CAP, COMPACT_PREFIX,
2208
+ runsDir, loadPlans, isDieuPhoiPrompt, foldPrompt, sinkStatusCached, sinkUrlKey, SINK_CACHE_TTL_MS, scoutBriefPath, quickHealth, startRun, startInfo, orchLessons, orchLessonLines, abortReasonOf, QUOTA_PAUSE_MARKER, stepIds, START_STEP_IDS, healthLine, formatStartBlock, handleUserPromptSubmit, START_PREFIX, buildSessionState, formatSessionState, stateCounts, compactSystemMessage, handleSessionStart, dispatchInSession, STATE_TEXT_CAP, COMPACT_PREFIX,
1636
2209
  CORE_VERSION, SCHEMA, TASK_TYPES, URGENCIES, ROLES, MODEL_ALIASES, UNTAGGED,
1637
2210
  sha8, nowIso, readJsonFile, writeFileAtomic, defaultClaudeDir, resolveHome, pathsFor, loadConfig, makeCtx,
1638
2211
  logError, computeUserHash, userHash, commonFields, parseTag, TASK_ID_RE, isValidTaskId, newTaskId, taskIdError, normalizeModel, loadPricing,
1639
2212
  priceFor, usageBuckets, requestCost, parseJsonl, readTranscript, lastUuidOf,
1640
- tagFromTranscript, summarizeTranscript, orchestratorUsage, orchCacheFile, findSessionTranscript, SESSION_ID_RE, HOOK_COLD_MAX_BYTES, appendEvent, eventLinesFrom, readEventLines, readEvents,
2213
+ firstPromptText, tagFromTranscript, summarizeTranscript,
2214
+ RESULT_ROLES, BLOCKED_RESULT_NAME_RE, resultFileName, findResultFiles, resultRunDir, finalAssistantText, persistInlineResult, orchestratorUsage, orchCacheFile, findSessionTranscript, SESSION_ID_RE, HOOK_COLD_MAX_BYTES, appendEvent, eventLinesFrom, readEventLines, readEvents,
1641
2215
  loadSyncOffset, saveSyncOffset, recordRejected, unsyncedInfo, loadSegments, saveSegments, segmentFile,
2216
+ HANDOFF_MARKER, HANDOFF_THRESHOLDS, GATE_MAX_CHILDREN, HELPER_AGENT_TYPE, watchAgentId, subagentTranscriptPath, readHeadTag, lastContextTokens, watchState, handoffMessage, handleWatch, handleGate, handleGateRelease, gateDir, gateSlots, gateReserve, gateConfirm, GATE_PENDING_MS,
1642
2217
  extractAgentId, hasAssistantUsage, hasDispatch, buildDispatch, handlePostToolUse, handleSubagentStop, maybeSpawnSync, runHook, runSync,
1643
2218
  };
1644
2219
 
@@ -1652,7 +2227,9 @@ if (require.main === module) {
1652
2227
  .catch((e) => logError(ctx.home, e, 'sync'))
1653
2228
  .finally(() => process.exit(0));
1654
2229
  } else {
1655
- const r = runHook();
2230
+ const mode = process.argv.includes('--watch') ? 'watch'
2231
+ : (process.argv.includes('--gate-release') ? 'gate-release' : (process.argv.includes('--gate') ? 'gate' : null));
2232
+ const r = runHook({ mode });
1656
2233
  if (r && typeof r.stdout === 'string') process.stdout.write(r.stdout);
1657
2234
  process.exitCode = 0;
1658
2235
  }