hanoman 0.1.5 → 0.1.6

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/dist/server.js CHANGED
@@ -4184,7 +4184,7 @@ function cmpVersion(a, b) {
4184
4184
  }
4185
4185
  return 0;
4186
4186
  }
4187
- var zProject, zBriefPayload, zQaPayload, zGoalPayload, zSpec, NOTIFY_SOUNDS, E_5_6, E_LUNA, E_BASE, CODEX_MODELS, RETIRED_CODEX_MODELS, zCodex, CODEX_DEFAULTS, zSourceCommon, zScheduler, SCHEDULER_DEFAULTS, zGoal, GOAL_DEFAULTS, zConflict, CONFLICT_DEFAULTS, zSetting, zNotification, zDocFile, zDeviceTokenView;
4187
+ var zProject, zBriefPayload, zQaPayload, zGoalPayload, zSpec, NOTIFY_SOUNDS, E_5_6, E_LUNA, E_BASE, CODEX_MODELS, RETIRED_CODEX_MODELS, zCodex, CODEX_DEFAULTS, zSourceCommon, zScheduler, SCHEDULER_DEFAULTS, zGoal, GOAL_DEFAULTS, zConflict, CONFLICT_DEFAULTS, zLeadEngine, zLead, LEAD_DEFAULTS, zSetting, zNotification, zDocFile, zDeviceTokenView;
4188
4188
  var init_entities = __esm({
4189
4189
  "../shared/src/entities.ts"() {
4190
4190
  "use strict";
@@ -4242,8 +4242,12 @@ var init_entities = __esm({
4242
4242
  // SPEC-407 · +goal
4243
4243
  branchFrom: external_exports.string().nullable(),
4244
4244
  // SPEC-143 · null = default project (main)
4245
- baseSha: external_exports.string().nullable()
4245
+ baseSha: external_exports.string().nullable(),
4246
4246
  // SPEC-186 · null = belum pernah ada sesi (belum dimulai)
4247
+ // SPEC-408 · ADR-0090 · stempel waktu backlog (ISO string di wire — kolom DateTime di DB).
4248
+ // `startedAt` null = belum pernah dikerjakan; ia tak pernah ditulis ulang saat sesi dilanjutkan.
4249
+ createdAt: external_exports.string(),
4250
+ startedAt: external_exports.string().nullable()
4247
4251
  });
4248
4252
  NOTIFY_SOUNDS = [
4249
4253
  "off",
@@ -4310,6 +4314,31 @@ var init_entities = __esm({
4310
4314
  effort: external_exports.string().default("xhigh")
4311
4315
  });
4312
4316
  CONFLICT_DEFAULTS = zConflict.parse({});
4317
+ zLeadEngine = external_exports.object({
4318
+ enabled: external_exports.boolean().default(false),
4319
+ agent: zAgent.default("claude"),
4320
+ model: external_exports.string().default("claude-opus-5"),
4321
+ effort: external_exports.string().default("xhigh")
4322
+ });
4323
+ zLead = external_exports.object({
4324
+ enabled: external_exports.boolean().default(false),
4325
+ // master switch (AC-30)
4326
+ paused: external_exports.boolean().default(false),
4327
+ // rem darurat global (AC-27)
4328
+ pausedProjects: external_exports.array(external_exports.string()).default([]),
4329
+ // rem per project (AC-15/US-4)
4330
+ everyMin: external_exports.number().int().min(1).max(1440).default(5),
4331
+ // denyut proaktif (OQ-2)
4332
+ timeoutSec: external_exports.number().int().min(10).max(900).default(120),
4333
+ // batas waktu satu putusan (AC-4/35)
4334
+ // AC-11 / OQ-10 · berapa jawaban otomatis berturut-turut untuk SATU sesi sebelum lead berhenti.
4335
+ maxAutoAnswers: external_exports.number().int().min(1).max(20).default(3),
4336
+ // OQ-3 · syarat objektif sebelum lead boleh mengintegrasikan ke branch utama. Default MENYALA:
4337
+ // risiko "kode masuk main tanpa mata manusia" diterima sadar, tapi syaratnya tetap terukur.
4338
+ requireGreenBeforeIntegrate: external_exports.boolean().default(true),
4339
+ engine: zLeadEngine.default({})
4340
+ });
4341
+ LEAD_DEFAULTS = zLead.parse({});
4313
4342
  zSetting = external_exports.object({
4314
4343
  model: external_exports.string().default("claude-opus-5"),
4315
4344
  effort: external_exports.string().default("xhigh"),
@@ -4336,13 +4365,17 @@ var init_entities = __esm({
4336
4365
  // SPEC-338 · ADR-0074 · model/effort codex
4337
4366
  verifyScope: zVerifyScope.default("changed"),
4338
4367
  // SPEC-376 · ADR-0080 · scope verifikasi sesi
4339
- conflict: zConflict.default(CONFLICT_DEFAULTS)
4368
+ conflict: zConflict.default(CONFLICT_DEFAULTS),
4340
4369
  // SPEC-383 · ADR-0081 · default sesi konflik rebase/merge
4370
+ lead: zLead.default(LEAD_DEFAULTS)
4371
+ // SPEC-409 · ADR-0091 · hanoman-lead (default mati)
4341
4372
  });
4342
4373
  zNotification = external_exports.object({
4343
4374
  id: external_exports.string(),
4344
- type: external_exports.enum(["done", "decision", "error", "ticket", "fail"]).default("done"),
4345
4375
  // SPEC-249 · +error; SPEC-253 · +ticket; SPEC-298 · +fail (sesi scheduler gagal/limit)
4376
+ // SPEC-409 · +lead (ADR-0091): keputusan berbobot / ragu / tindakan terkunci ditolak. MEMBERI
4377
+ // TAHU, bukan meminta izin — tak ada pekerjaan yang menunggu notifikasi ini dibaca (AC-25).
4378
+ type: external_exports.enum(["done", "decision", "error", "ticket", "fail", "lead"]).default("done"),
4346
4379
  specId: external_exports.string().nullable(),
4347
4380
  sessionId: external_exports.string().nullable(),
4348
4381
  title: external_exports.string(),
@@ -4397,7 +4430,13 @@ var init_agent = __esm({
4397
4430
  "support:read",
4398
4431
  "support:write",
4399
4432
  "notifications:read",
4400
- "notifications:write"
4433
+ "notifications:write",
4434
+ // SPEC-409 · ADR-0091 · minta putusan ke hanoman-lead + baca jejak keputusan. Domain TERSENDIRI,
4435
+ // bukan menumpang prefix baca yang sudah ada: meminta putusan adalah operasi TULIS (ia melahirkan
4436
+ // baris jejak & bisa menggerakkan sesi), dan SPEC-405 sudah membuktikan apa yang terjadi saat
4437
+ // endpoint tulis menumpang prefix status yang dipetakan tanpa melihat method (AC-5).
4438
+ "lead:read",
4439
+ "lead:write"
4401
4440
  ];
4402
4441
  zCapability = external_exports.enum(CAPABILITY_IDS);
4403
4442
  zCapabilityInfo = external_exports.object({
@@ -4426,7 +4465,9 @@ var init_agent = __esm({
4426
4465
  { id: "support:read", domain: "support", access: "read", label: "Errors & Tiket \u2014 baca", desc: "Lihat error monitoring & tiket Help Center." },
4427
4466
  { id: "support:write", domain: "support", access: "write", label: "Errors & Tiket \u2014 tulis", desc: "Eskalasi error, ubah status, terima/tolak tiket." },
4428
4467
  { id: "notifications:read", domain: "notifications", access: "read", label: "Notifikasi \u2014 baca", desc: "Lihat notifikasi." },
4429
- { id: "notifications:write", domain: "notifications", access: "write", label: "Notifikasi \u2014 tulis", desc: "Tandai terbaca / bersihkan notifikasi." }
4468
+ { id: "notifications:write", domain: "notifications", access: "write", label: "Notifikasi \u2014 tulis", desc: "Tandai terbaca / bersihkan notifikasi." },
4469
+ { id: "lead:read", domain: "lead", access: "read", label: "Lead \u2014 baca", desc: "Baca jejak keputusan hanoman-lead & statusnya." },
4470
+ { id: "lead:write", domain: "lead", access: "write", label: "Lead \u2014 tulis", desc: "Minta putusan ke hanoman-lead (keputusan bisa menggerakkan sesi).", risk: "exec" }
4430
4471
  ];
4431
4472
  zAgentTokenView = external_exports.object({
4432
4473
  id: external_exports.string(),
@@ -4451,16 +4492,103 @@ var init_agent = __esm({
4451
4492
  }
4452
4493
  });
4453
4494
 
4495
+ // ../shared/src/lead.ts
4496
+ function leadActionAllowed(action) {
4497
+ return LEAD_ACTIONS.includes(action);
4498
+ }
4499
+ function leadRefusalReason(action) {
4500
+ return LEAD_FORBIDDEN[action] ?? `tindakan "${action}" di luar permukaan tindakan lead`;
4501
+ }
4502
+ function isWeightyDecision(o) {
4503
+ if (o.confidence === "ragu") return true;
4504
+ if (o.kind === "collision" || o.kind === "refusal") return true;
4505
+ return o.action === "integrate-main" || o.action === "run-migration" || o.action === "stop-session" || o.action === "restart-session";
4506
+ }
4507
+ var zLeadGate, zLeadKind, zLeadConfidence, zLeadStatus, LEAD_ACTIONS, zLeadAction, LEAD_FORBIDDEN, zLeadVerdict, zLeadAsk, zLeadOverride;
4508
+ var init_lead = __esm({
4509
+ "../shared/src/lead.ts"() {
4510
+ "use strict";
4511
+ init_zod();
4512
+ zLeadGate = external_exports.enum(["contract", "detected", "pulse"]);
4513
+ zLeadKind = external_exports.enum(["answer", "order", "collision", "quality", "refusal"]);
4514
+ zLeadConfidence = external_exports.enum(["tinggi", "sedang", "ragu"]);
4515
+ zLeadStatus = external_exports.enum(["berlaku", "ditimpa", "dibatalkan", "gagal"]);
4516
+ LEAD_ACTIONS = [
4517
+ "none",
4518
+ // keputusan murni jawaban; tak ada tindakan menyusul
4519
+ "answer-session",
4520
+ // ketik jawaban ke pane sesi (pintu deteksi otomatis)
4521
+ "start-session",
4522
+ // mulai sesi untuk sebuah backlog (lewat antrean scheduler)
4523
+ "stop-session",
4524
+ // hentikan sesi — worktree DIBIARKAN utuh (AC-32a)
4525
+ "resume-session",
4526
+ // lanjutkan sesi terputus (jalur ADR-0084)
4527
+ "restart-session",
4528
+ // ulangi pekerjaan dari awal
4529
+ "order-queue",
4530
+ // tata urutan antrean yang sudah ada (ADR-0072) — bukan antrean kedua
4531
+ "hold-work",
4532
+ // tunda satu pekerjaan karena tabrakan area kerja
4533
+ "push-branch",
4534
+ // dorong perubahan ke branch
4535
+ "integrate-main",
4536
+ // integrasikan ke branch utama (risiko yang diterima sadar, PRD §Risiko)
4537
+ "run-migration"
4538
+ // jalankan migration pada basis data LOKAL operator
4539
+ ];
4540
+ zLeadAction = external_exports.enum(LEAD_ACTIONS);
4541
+ LEAD_FORBIDDEN = {
4542
+ deploy: "deploy ke produksi",
4543
+ "vps-exec": "menjalankan perintah di VPS",
4544
+ "vps-console": "membuka konsol VPS",
4545
+ "prod-data": "menyentuh data produksi",
4546
+ "delete-project": "menghapus project",
4547
+ "delete-spec": "menghapus backlog",
4548
+ "delete-branch": "menghapus branch",
4549
+ "delete-worktree": "menghapus worktree",
4550
+ "delete-notification": "menghapus notifikasi",
4551
+ "delete-decision": "menghapus jejak keputusan"
4552
+ };
4553
+ zLeadVerdict = external_exports.object({
4554
+ decision: external_exports.string().min(1),
4555
+ reason: external_exports.string().min(1),
4556
+ refs: external_exports.array(external_exports.string()).default([]),
4557
+ confidence: zLeadConfidence.default("sedang"),
4558
+ // Sengaja `string`, BUKAN `zLeadAction`: tindakan di luar allowlist harus bisa MASUK supaya
4559
+ // server menolaknya secara sadar — mencatat penolakan & menotifikasi operator (AC-33). Kalau
4560
+ // enum yang menyaring di sini, permintaan "deploy ke produksi" hanya akan tampak sebagai
4561
+ // keluaran rusak, dan justru peristiwa paling layak dilaporkan itulah yang hilang dari jejak.
4562
+ action: external_exports.string().default("none"),
4563
+ /** Teks yang benar-benar diketik ke pane sesi (pintu deteksi otomatis). */
4564
+ reply: external_exports.string().default("")
4565
+ });
4566
+ zLeadAsk = external_exports.object({
4567
+ projectId: external_exports.string().min(1),
4568
+ specId: external_exports.string().nullish(),
4569
+ sessionId: external_exports.string().nullish(),
4570
+ question: external_exports.string().min(1).max(8e3),
4571
+ options: external_exports.array(external_exports.string().max(2e3)).max(20).default([]),
4572
+ context: external_exports.string().max(2e4).default("")
4573
+ });
4574
+ zLeadOverride = external_exports.object({
4575
+ answer: external_exports.string().min(1).max(8e3),
4576
+ reason: external_exports.string().max(8e3).default("")
4577
+ });
4578
+ }
4579
+ });
4580
+
4454
4581
  // ../shared/src/dto.ts
4455
4582
  function flowForSource(source) {
4456
4583
  return source === "qa" ? "qa" : source === "audit" ? "audit" : source === "cross-audit" ? "cross-audit" : source === "goal" ? "goal" : "feature";
4457
4584
  }
4458
- var zIssueDeviceToken, zSessionResult, zSessionHistory, zCreateProject, zProjectId, zRenameProject, zCreateLink, zUpdateProject, zCreateSpec, zPatchSpec, zIntegrate, zSessionSummary, zProjectView, zSchedulerQueueItem, zSchedulerSourceView, zSchedulerSessionView, zSchedulerState, zFlow, zPrdBrief, zPrdDoc, zBreakdownItem, zBreakdownDoc, zBatchCreateSpec, zEscalationTarget, zEscalationPrefill, zAuditEscalation, zAuditEscalationView, zTerminalSession, zDocFileContent, zDocIndexCat, zDocIndex, HOST_RE, USER_RE, zCreateVps, zPatchVps, zLogin, zSignup, zChangePassword, zVpsCheck, zMarkNa, zMarkNaBulk, zAttest, zRemediate, UPDATE_RESTART_EXIT, zUpdateApplyBody, zStackFrame, zSymbolicatedFrame, zSourceMapUpload, zIngestPayload, zErrorGroupView, zErrorEventView, zErrorGroupDetail, zIngestKeyView, zTicketView, zTicketAttachmentView, zTicketDetail, zTicketEditInput, zHelpInfo, zPublicTicketStatus;
4585
+ var zIssueDeviceToken, zSessionResult, zSessionHistory, zCreateProject, zProjectId, zRenameProject, zCreateLink, zUpdateProject, zCreateSpec, zPatchSpec, zIntegrate, zSessionSummary, zProjectView, zSchedulerQueueItem, zSchedulerSourceView, zSchedulerSessionView, zSchedulerState, zLeadDecisionView, zLeadAnswer, zLeadProjectStatus, zLeadStatusView, zFlow, zPrdBrief, zPrdDoc, zBreakdownItem, zBreakdownDoc, zBatchCreateSpec, zEscalationTarget, zEscalationPrefill, zAuditEscalation, zAuditEscalationView, zTerminalSession, zDocFileContent, zDocIndexCat, zDocIndex, HOST_RE, USER_RE, zCreateVps, zPatchVps, zLogin, zSignup, zChangePassword, zVpsCheck, zMarkNa, zMarkNaBulk, zAttest, zRemediate, UPDATE_RESTART_EXIT, zUpdateApplyBody, zStackFrame, zSymbolicatedFrame, zSourceMapUpload, zIngestPayload, zErrorGroupView, zErrorEventView, zErrorGroupDetail, zIngestKeyView, zTicketView, zTicketAttachmentView, zTicketDetail, zTicketEditInput, zHelpInfo, zPublicTicketStatus;
4459
4586
  var init_dto = __esm({
4460
4587
  "../shared/src/dto.ts"() {
4461
4588
  "use strict";
4462
4589
  init_zod();
4463
4590
  init_entities();
4591
+ init_lead();
4464
4592
  init_enums();
4465
4593
  zIssueDeviceToken = external_exports.object({ name: external_exports.string().min(1) });
4466
4594
  zSessionResult = external_exports.object({
@@ -4516,8 +4644,10 @@ var init_dto = __esm({
4516
4644
  // SPEC-213 · set git remote resmi project
4517
4645
  repoDir: external_exports.string().nullable().optional(),
4518
4646
  // SPEC-217 · path default/server editable (null = kosongkan)
4519
- schedulerOptIn: external_exports.boolean().optional()
4647
+ schedulerOptIn: external_exports.boolean().optional(),
4520
4648
  // SPEC-294 · opt-in scheduler otonom (lokal, tak disync)
4649
+ leadOptIn: external_exports.boolean().optional()
4650
+ // SPEC-409 · ADR-0091 · opt-in hanoman-lead (lokal, tak disync)
4521
4651
  });
4522
4652
  zCreateSpec = external_exports.object({
4523
4653
  project: external_exports.string(),
@@ -4565,7 +4695,9 @@ var init_dto = __esm({
4565
4695
  // SPEC-249 · hint prefix DSN (bukan hash/rahasia)
4566
4696
  helpEnabled: external_exports.boolean().default(false),
4567
4697
  // SPEC-253 · Help Center publik aktif
4568
- schedulerOptIn: external_exports.boolean().default(false)
4698
+ schedulerOptIn: external_exports.boolean().default(false),
4699
+ // SPEC-294 · opt-in scheduler otonom
4700
+ leadOptIn: external_exports.boolean().default(false)
4569
4701
  });
4570
4702
  zSchedulerQueueItem = external_exports.object({
4571
4703
  id: external_exports.string(),
@@ -4605,6 +4737,50 @@ var init_dto = __esm({
4605
4737
  queue: external_exports.array(zSchedulerQueueItem),
4606
4738
  sessions: external_exports.array(zSchedulerSessionView)
4607
4739
  });
4740
+ zLeadDecisionView = external_exports.object({
4741
+ id: external_exports.string(),
4742
+ projectId: external_exports.string(),
4743
+ specId: external_exports.string().nullable(),
4744
+ sessionId: external_exports.string().nullable(),
4745
+ gate: zLeadGate,
4746
+ kind: zLeadKind,
4747
+ question: external_exports.string(),
4748
+ answer: external_exports.string(),
4749
+ reason: external_exports.string(),
4750
+ refs: external_exports.array(external_exports.string()),
4751
+ confidence: zLeadConfidence,
4752
+ action: zLeadAction,
4753
+ status: zLeadStatus,
4754
+ weighty: external_exports.boolean(),
4755
+ supersededById: external_exports.string().nullable(),
4756
+ createdAt: external_exports.string()
4757
+ });
4758
+ zLeadAnswer = external_exports.object({
4759
+ id: external_exports.string(),
4760
+ decision: external_exports.string(),
4761
+ reason: external_exports.string(),
4762
+ refs: external_exports.array(external_exports.string()),
4763
+ confidence: zLeadConfidence,
4764
+ action: zLeadAction
4765
+ });
4766
+ zLeadProjectStatus = external_exports.object({
4767
+ projectId: external_exports.string(),
4768
+ name: external_exports.string(),
4769
+ optIn: external_exports.boolean(),
4770
+ paused: external_exports.boolean(),
4771
+ decisions24h: external_exports.number(),
4772
+ openSessions: external_exports.number()
4773
+ });
4774
+ zLeadStatusView = external_exports.object({
4775
+ config: zLead,
4776
+ projects: external_exports.array(zLeadProjectStatus),
4777
+ queue: external_exports.array(zSchedulerQueueItem),
4778
+ deciding: external_exports.array(external_exports.string()),
4779
+ // id sesi yang sedang disusun keputusannya (AC-3)
4780
+ waiting: external_exports.array(external_exports.string()),
4781
+ // id sesi ber-marker keputusan terisi
4782
+ lastPulseAt: external_exports.string().nullable()
4783
+ });
4608
4784
  zFlow = external_exports.enum(["feature", "qa", "scaffold", "reverse", "prd", "audit", "breakdown", "cross-audit", "goal"]);
4609
4785
  zPrdBrief = external_exports.object({
4610
4786
  title: external_exports.string().min(1),
@@ -4995,6 +5171,12 @@ var init_api = __esm({
4995
5171
  // SPEC-299 · panel scheduler (daun #6) — konsumen read-only fondasi SPEC-294/ADR-0072.
4996
5172
  schedulerConfig: `${API}/scheduler/config`,
4997
5173
  schedulerState: `${API}/scheduler/state`,
5174
+ // SPEC-409 · ADR-0091 · hanoman-lead. Semua HTTP (polling) — tak ada kanal WS baru (AC-26).
5175
+ leadConfig: `${API}/lead/config`,
5176
+ leadStatus: `${API}/lead/status`,
5177
+ leadDecisions: `${API}/lead/decisions`,
5178
+ leadDecisionOverride: (id) => `${API}/lead/decisions/${encodeURIComponent(id)}/override`,
5179
+ leadDecisionCancel: (id) => `${API}/lead/decisions/${encodeURIComponent(id)}/cancel`,
4998
5180
  // SPEC-268 · ADR-0066 · pemicu sync manual (cookie-authed)
4999
5181
  syncNow: `${API}/sync/now`,
5000
5182
  // SPEC-270 · ADR-0067 · antrean konflik rekonsil (cookie-authed)
@@ -5328,6 +5510,7 @@ var init_src = __esm({
5328
5510
  init_enums();
5329
5511
  init_entities();
5330
5512
  init_agent();
5513
+ init_lead();
5331
5514
  init_dto();
5332
5515
  init_api();
5333
5516
  init_coverage();
@@ -6520,6 +6703,9 @@ function createSession(projectId, cwd, opts = {}) {
6520
6703
  argv = [sq(agentBin(agent)), promptArg, flags].filter(Boolean).join(" ");
6521
6704
  }
6522
6705
  const envPairs = [];
6706
+ if (!opts.command && agent === "claude") {
6707
+ for (const [k, v] of Object.entries(rootBypassEnv())) envPairs.push(`${k}=${sq(v)}`);
6708
+ }
6523
6709
  if (opts.phaseFile) {
6524
6710
  mkdirSync3(dirname4(opts.phaseFile), { recursive: true });
6525
6711
  envPairs.push(`HANOMAN_PHASE_FILE=${sq(opts.phaseFile)}`);
@@ -6610,6 +6796,29 @@ function createSession(projectId, cwd, opts = {}) {
6610
6796
  });
6611
6797
  return { id, projectId, specId: opts.specId, flow: opts.flow, cwd, branch: opts.branch, exited: false, decision: false, agent };
6612
6798
  }
6799
+ function capturePane(id, lines2 = 200) {
6800
+ try {
6801
+ return tmux("capture-pane", "-p", "-J", "-S", `-${lines2}`, "-t", name(id));
6802
+ } catch {
6803
+ return "";
6804
+ }
6805
+ }
6806
+ async function sendToPane(id, text, chunkMs = 50) {
6807
+ const p = getSession(id);
6808
+ if (!p || p.exited) return false;
6809
+ const line = text.replace(/\s*\r?\n\s*/g, " ").trim();
6810
+ if (!line) return false;
6811
+ try {
6812
+ for (const chunk of goalChunks(line)) {
6813
+ tmux("send-keys", "-t", name(id), "-l", chunk);
6814
+ await sleep(chunkMs);
6815
+ }
6816
+ tmux("send-keys", "-t", name(id), "Enter");
6817
+ return true;
6818
+ } catch {
6819
+ return false;
6820
+ }
6821
+ }
6613
6822
  async function armGoalInTui(id, condition, o = {}) {
6614
6823
  const pollMs = o.pollMs ?? 500, readyTries = o.readyTries ?? 20;
6615
6824
  const settleMs = o.settleMs ?? 1200, verifyTries = o.verifyTries ?? 12;
@@ -6782,7 +6991,7 @@ function spawnPty(...args) {
6782
6991
  );
6783
6992
  }
6784
6993
  }
6785
- var socket, PREFIX, MAX_SCROLLBACK, POLL_MS, markerFilled, attached, claudeBin, shellBin, codexBin, agentBin, frame, name, promptFilePath, goalGatePath, goalStatePath, NO_SERVER, TmuxError, sq, sessionIdForSpec, idFor, FMT, listSessions, liveDecisions, getSession, hooks, emitBirth, emitDeath, sleep, paneText, GOAL_ARMED_MARKERS, goalArmed, poll, detach;
6994
+ var socket, PREFIX, MAX_SCROLLBACK, POLL_MS, markerFilled, attached, claudeBin, shellBin, codexBin, agentBin, rootBypassEnv, frame, name, promptFilePath, goalGatePath, goalStatePath, NO_SERVER, TmuxError, sq, sessionIdForSpec, idFor, FMT, listSessions, liveDecisions, getSession, hooks, emitBirth, emitDeath, sleep, paneText, GOAL_ARMED_MARKERS, goalArmed, poll, detach;
6786
6995
  var init_pty = __esm({
6787
6996
  "src/services/pty.ts"() {
6788
6997
  "use strict";
@@ -6806,6 +7015,7 @@ var init_pty = __esm({
6806
7015
  shellBin = () => effectiveStr("HANOMAN_SHELL") ?? process.env.SHELL ?? "/bin/bash";
6807
7016
  codexBin = () => effectiveStr("HANOMAN_CODEX_BIN") ?? "codex";
6808
7017
  agentBin = (agent) => agent === "codex" ? codexBin() : claudeBin();
7018
+ rootBypassEnv = (uid = process.getuid?.()) => uid === 0 ? { IS_SANDBOX: "1" } : {};
6809
7019
  frame = (f) => JSON.stringify(f);
6810
7020
  name = (id) => PREFIX + id;
6811
7021
  promptFilePath = (id) => `${tmpdir()}/hanoman-prompts/${id}`;
@@ -7129,7 +7339,9 @@ var init_sync = __esm({
7129
7339
  };
7130
7340
  FIELDS = {
7131
7341
  project: ["name", "desc", "kind", "stack", "gitRemote", "updatedAt"],
7132
- spec: ["projectId", "title", "source", "stage", "priority", "author", "objective", "payload", "branchFrom", "baseSha", "headSha", "updatedAt"],
7342
+ // SPEC-408 · ADR-0090 · createdAt/startedAt ikut menyeberang sejajar baseSha/headSha. Tanpa
7343
+ // ini spec asal-hub mendapat createdAt lokal palsu di tiap client (kolom NOT NULL ber-default).
7344
+ spec: ["projectId", "title", "source", "stage", "priority", "author", "objective", "payload", "branchFrom", "baseSha", "headSha", "createdAt", "startedAt", "updatedAt"],
7133
7345
  vps: ["name", "host", "port", "user", "health", "audit", "hardened", "lastSeenAt", "lastAuditAt", "updatedAt"],
7134
7346
  sessionResult: ["projectId", "specId", "oldStage", "newStage", "commitSha", "branch", "prUrl", "status", "deviceId", "author", "createdAt", "updatedAt"],
7135
7347
  // SPEC-268 · ADR-0066 · agregat grup error (ErrorEvent mentah tak disync).
@@ -7142,7 +7354,7 @@ var init_sync = __esm({
7142
7354
  };
7143
7355
  DATE_FIELDS = {
7144
7356
  project: ["updatedAt"],
7145
- spec: ["updatedAt"],
7357
+ spec: ["createdAt", "startedAt", "updatedAt"],
7146
7358
  vps: ["lastSeenAt", "lastAuditAt", "updatedAt"],
7147
7359
  sessionResult: ["createdAt", "updatedAt"],
7148
7360
  errorGroup: ["firstSeenAt", "lastSeenAt", "updatedAt"],
@@ -11111,7 +11323,7 @@ function syncStatus() {
11111
11323
  async function startSyncClient(base, token, tickMs2) {
11112
11324
  started = true;
11113
11325
  const transport = fetchTransport(base, token);
11114
- const tick3 = async () => {
11326
+ const tick4 = async () => {
11115
11327
  try {
11116
11328
  await syncOnce(transport);
11117
11329
  } catch {
@@ -11122,13 +11334,13 @@ async function startSyncClient(base, token, tickMs2) {
11122
11334
  const wsUrl = base.replace(/^http/, "ws").replace(/\/$/, "") + `/api/sync/ws?token=${encodeURIComponent(token)}`;
11123
11335
  ws = new WebSocket2(wsUrl);
11124
11336
  ws.on("open", () => {
11125
- void tick3();
11337
+ void tick4();
11126
11338
  });
11127
11339
  ws.on("message", async (raw) => {
11128
11340
  try {
11129
11341
  const msg = JSON.parse(raw.toString());
11130
11342
  if (msg.t !== "sync") return;
11131
- if (!await applyFeedFrame(msg)) void tick3();
11343
+ if (!await applyFeedFrame(msg)) void tick4();
11132
11344
  } catch {
11133
11345
  }
11134
11346
  });
@@ -11145,11 +11357,11 @@ async function startSyncClient(base, token, tickMs2) {
11145
11357
  }
11146
11358
  });
11147
11359
  };
11148
- await tick3();
11360
+ await tick4();
11149
11361
  void connectWs();
11150
11362
  const ms = tickMs2 && tickMs2 > 0 ? tickMs2 : 15e3;
11151
11363
  timer2 = setInterval(() => {
11152
- void tick3();
11364
+ void tick4();
11153
11365
  }, ms);
11154
11366
  timer2.unref?.();
11155
11367
  }
@@ -14419,7 +14631,7 @@ import websocket from "@fastify/websocket";
14419
14631
  import cookie from "@fastify/cookie";
14420
14632
  import { fileURLToPath as fileURLToPath4 } from "node:url";
14421
14633
  import { dirname as dirname12 } from "node:path";
14422
- import { existsSync as existsSync10 } from "node:fs";
14634
+ import { existsSync as existsSync11 } from "node:fs";
14423
14635
 
14424
14636
  // src/web-dir.ts
14425
14637
  import { resolve } from "node:path";
@@ -14638,7 +14850,9 @@ async function toProjectView(p, sessions) {
14638
14850
  // SPEC-253 · Help Center publik aktif.
14639
14851
  helpEnabled: p.helpEnabled,
14640
14852
  // SPEC-294 · opt-in scheduler otonom (lokal per-instance).
14641
- schedulerOptIn: p.schedulerOptIn
14853
+ schedulerOptIn: p.schedulerOptIn,
14854
+ // SPEC-409 · ADR-0091 · opt-in hanoman-lead (lokal per-instance, cermin schedulerOptIn).
14855
+ leadOptIn: p.leadOptIn
14642
14856
  };
14643
14857
  }
14644
14858
 
@@ -15091,8 +15305,10 @@ var DEFAULT_SETTING = {
15091
15305
  // SPEC-338 · ADR-0074 · model/effort codex
15092
15306
  verifyScope: "changed",
15093
15307
  // SPEC-376 · ADR-0080 · uji hanya yang berubah
15094
- conflict: CONFLICT_DEFAULTS
15308
+ conflict: CONFLICT_DEFAULTS,
15095
15309
  // SPEC-383 · ADR-0081 · default sesi konflik (opt-in, mati)
15310
+ lead: LEAD_DEFAULTS
15311
+ // SPEC-409 · ADR-0091 · hanoman-lead (master switch mati)
15096
15312
  };
15097
15313
  async function getSetting() {
15098
15314
  const raw = (await prisma.setting.findUnique({ where: { id: 1 } }))?.data;
@@ -17814,6 +18030,27 @@ async function sendReviewDownload(reply, fmt, rf, a) {
17814
18030
  });
17815
18031
  }
17816
18032
 
18033
+ // src/services/date-range.ts
18034
+ var DAY = /^\d{4}-\d{2}-\d{2}$/;
18035
+ function dayStart(s) {
18036
+ if (!s || !DAY.test(s)) return null;
18037
+ const [y, m, d] = s.split("-").map(Number);
18038
+ const dt = new Date(y, m - 1, d, 0, 0, 0, 0);
18039
+ if (dt.getFullYear() !== y || dt.getMonth() !== m - 1 || dt.getDate() !== d) return null;
18040
+ return dt;
18041
+ }
18042
+ function dayEnd(s) {
18043
+ const start = dayStart(s);
18044
+ if (!start) return null;
18045
+ return new Date(start.getFullYear(), start.getMonth(), start.getDate(), 23, 59, 59, 999);
18046
+ }
18047
+ function inDayRange(at, from, to) {
18048
+ if (!from && !to) return true;
18049
+ if (!at) return false;
18050
+ const t = at.getTime();
18051
+ return (!from || t >= from.getTime()) && (!to || t <= to.getTime());
18052
+ }
18053
+
17817
18054
  // src/services/live-specs.ts
17818
18055
  init_db();
17819
18056
  init_pty();
@@ -17863,6 +18100,12 @@ async function recordNewTicket(ticketId, projectId, projectName, category, title
17863
18100
  }).catch(() => {
17864
18101
  });
17865
18102
  }
18103
+ async function recordLeadDecision(decisionId, title, projectId, specId, sessionId2) {
18104
+ await prisma.notification.create({
18105
+ data: { type: "lead", key: `lead:${decisionId}`, specId, sessionId: sessionId2, projectId, title }
18106
+ }).catch(() => {
18107
+ });
18108
+ }
17866
18109
  var awaiting = /* @__PURE__ */ new Set();
17867
18110
  async function scanDecisions(read = liveDecisions) {
17868
18111
  const next = /* @__PURE__ */ new Set();
@@ -17932,13 +18175,16 @@ function deriveSpecFields(source, payload, manualPriority) {
17932
18175
  }
17933
18176
  function filterSpecs(specs, f) {
17934
18177
  const needle = (f.q ?? "").trim().toLowerCase();
17935
- return specs.filter((s) => (!f.stage || s.stage === f.stage) && (!f.priority || s.priority === f.priority) && (f.startable !== "true" || s.stage !== "done") && (needle === "" || `${s.id} ${s.title} ${s.objective}`.toLowerCase().includes(needle)));
18178
+ const from = dayStart(f.from);
18179
+ const to = dayEnd(f.to);
18180
+ const byStarted = f.dateField === "started";
18181
+ return specs.filter((s) => (!f.stage || s.stage === f.stage) && (!f.priority || s.priority === f.priority) && (f.startable !== "true" || s.stage !== "done") && inDayRange(byStarted ? s.startedAt : s.createdAt, from, to) && (needle === "" || `${s.id} ${s.title} ${s.objective}`.toLowerCase().includes(needle)));
17936
18182
  }
17937
18183
  async function specs_default(app2) {
17938
18184
  app2.get("/specs", async (req) => {
17939
- const { project, source, q, stage, priority, startable, page, limit } = req.query;
18185
+ const { project, source, q, stage, priority, startable, dateField, from, to, page, limit } = req.query;
17940
18186
  const overlaid = await liveSpecs({ project, source });
17941
- return paginate(filterSpecs(overlaid, { q, stage, priority, startable }), page, limit);
18187
+ return paginate(filterSpecs(overlaid, { q, stage, priority, startable, dateField, from, to }), page, limit);
17942
18188
  });
17943
18189
  app2.post("/specs", async (req, reply) => {
17944
18190
  const parsed = zCreateSpec.safeParse(req.body);
@@ -19429,7 +19675,10 @@ async function startSpecSession(spec, opts) {
19429
19675
  } catch (e) {
19430
19676
  throw new LaunchError(`gagal membuat worktree: ${e.message}`, "worktree");
19431
19677
  }
19432
- if (!resume) await prisma.spec.update({ where: { id: spec.id }, data: { baseSha, headSha: null } });
19678
+ if (!resume) await prisma.spec.update({
19679
+ where: { id: spec.id },
19680
+ data: { baseSha, headSha: null, startedAt: /* @__PURE__ */ new Date() }
19681
+ });
19433
19682
  }
19434
19683
  const brief = {
19435
19684
  id: spec.id,
@@ -20006,9 +20255,9 @@ function sshExec(t, remoteCmd, opts = {}) {
20006
20255
  return new Promise((resolve15) => {
20007
20256
  const p = spawn3(sshBin(), args, { stdio: ["pipe", "pipe", "pipe"], env });
20008
20257
  let out3 = "";
20009
- const timer4 = setTimeout(() => p.kill("SIGKILL"), opts.timeoutMs ?? 6e4);
20258
+ const timer5 = setTimeout(() => p.kill("SIGKILL"), opts.timeoutMs ?? 6e4);
20010
20259
  const done = (r) => {
20011
- clearTimeout(timer4);
20260
+ clearTimeout(timer5);
20012
20261
  if (askpass) rmSync4(dirname9(askpass), { recursive: true, force: true });
20013
20262
  resolve15(r);
20014
20263
  };
@@ -20890,12 +21139,36 @@ async function update(app2) {
20890
21139
 
20891
21140
  // src/services/events.ts
20892
21141
  init_pty();
21142
+
21143
+ // src/services/lead/deciding.ts
21144
+ var deciding = /* @__PURE__ */ new Set();
21145
+ function markDeciding(sessionId2) {
21146
+ deciding.add(sessionId2);
21147
+ }
21148
+ function clearDeciding(sessionId2) {
21149
+ deciding.delete(sessionId2);
21150
+ }
21151
+ function isDeciding(sessionId2) {
21152
+ return deciding.has(sessionId2);
21153
+ }
21154
+ function decidingIds() {
21155
+ return [...deciding];
21156
+ }
21157
+
21158
+ // src/services/events.ts
20893
21159
  init_db();
20894
21160
  init_config2();
20895
21161
  var clients = /* @__PURE__ */ new Set();
20896
21162
  var tickMs = () => effectiveInt("HANOMAN_EVENTS_TICK_MS") ?? 1e3;
20897
21163
  var GROUPS = [
20898
- { everyTicks: 1, last: "", build: async () => ({ t: "sessions", sessions: listSessions() }) },
21164
+ // SPEC-409 · ADR-0091 · AC-3 · `deciding` menandai sesi yang sedang DISUSUN keputusannya oleh
21165
+ // hanoman-lead. Tanpa penanda ini sesi yang justru sedang dilayani terbaca persis seperti sesi
21166
+ // yang mandek — bentuknya sama: diam, marker keputusan terisi. Dihias DI SINI, bukan di pty.ts:
21167
+ // service itu sengaja tak tahu apa-apa soal lead maupun DB.
21168
+ { everyTicks: 1, last: "", build: async () => ({
21169
+ t: "sessions",
21170
+ sessions: listSessions().map((s) => isDeciding(s.id) ? { ...s, deciding: true } : s)
21171
+ }) },
20899
21172
  { everyTicks: 1, last: "", build: async () => ({ t: "specs", specs: await liveSpecs() }) },
20900
21173
  { everyTicks: 3, last: "", build: async () => ({ t: "notifications", ...await notificationsFeed() }) },
20901
21174
  // ponytail: cermin GET /vps (orderBy createdAt asc). Query sepele — tak diekstrak.
@@ -21312,11 +21585,11 @@ function clamp(text) {
21312
21585
  const buf = Buffer.from(text, "utf8");
21313
21586
  if (buf.byteLength <= MAX_TRANSCRIPT_BYTES) return { body: text, truncated: false };
21314
21587
  const cut = buf.byteLength - MAX_TRANSCRIPT_BYTES;
21315
- let tail = buf.subarray(cut).toString("utf8");
21316
- const nl = tail.indexOf("\n");
21317
- if (nl >= 0) tail = tail.slice(nl + 1);
21588
+ let tail2 = buf.subarray(cut).toString("utf8");
21589
+ const nl = tail2.indexOf("\n");
21590
+ if (nl >= 0) tail2 = tail2.slice(nl + 1);
21318
21591
  return { body: `\u2026 ${cut} byte awal dipangkas (batas ${MAX_TRANSCRIPT_BYTES} byte) \u2026
21319
- ${tail}`, truncated: true };
21592
+ ${tail2}`, truncated: true };
21320
21593
  }
21321
21594
  async function saveTranscript(text) {
21322
21595
  if (!text.trim()) return { key: "", bytes: 0, truncated: false };
@@ -21871,7 +22144,7 @@ for (let i = 0; i < chars.length; i++) {
21871
22144
  intToChar[i] = c;
21872
22145
  charToInt[c] = i;
21873
22146
  }
21874
- function decodeInteger(reader, relative) {
22147
+ function decodeInteger(reader, relative2) {
21875
22148
  let value = 0;
21876
22149
  let shift = 0;
21877
22150
  let integer = 0;
@@ -21886,7 +22159,7 @@ function decodeInteger(reader, relative) {
21886
22159
  if (shouldNegate) {
21887
22160
  value = -2147483648 | -value;
21888
22161
  }
21889
- return relative + value;
22162
+ return relative2 + value;
21890
22163
  }
21891
22164
  function hasMoreVlq(reader, max) {
21892
22165
  if (reader.pos >= max) return false;
@@ -23046,6 +23319,1038 @@ async function audit_default(app2) {
23046
23319
  });
23047
23320
  }
23048
23321
 
23322
+ // src/routes/lead.ts
23323
+ init_src();
23324
+ init_db();
23325
+ init_pty();
23326
+
23327
+ // src/services/lead/config.ts
23328
+ init_db();
23329
+ init_src();
23330
+ async function getLead() {
23331
+ return (await getSetting()).lead;
23332
+ }
23333
+ async function setLead(next) {
23334
+ const cur = await getSetting();
23335
+ const data = { ...cur, lead: next };
23336
+ await prisma.setting.upsert({ where: { id: 1 }, update: { data }, create: { id: 1, data } });
23337
+ return next;
23338
+ }
23339
+ async function leadAgentDefaults() {
23340
+ const s = await getSetting();
23341
+ const e = s.lead.engine;
23342
+ if (!e.enabled) return sessionAgentDefaults();
23343
+ return e.agent === "codex" ? { agent: "codex", model: e.model, effort: coerceCodexEffort(e.model, e.effort) } : { agent: "claude", model: e.model, effort: e.effort };
23344
+ }
23345
+ function leadActive(cfg, projectId) {
23346
+ if (!cfg.enabled || cfg.paused) return false;
23347
+ if (projectId && cfg.pausedProjects.includes(projectId)) return false;
23348
+ return true;
23349
+ }
23350
+ async function leadProjects() {
23351
+ const rows = await prisma.project.findMany({ where: { leadOptIn: true }, select: { id: true } });
23352
+ return rows.map((r) => r.id);
23353
+ }
23354
+
23355
+ // src/services/lead/decide.ts
23356
+ init_src();
23357
+ init_db();
23358
+ init_pty();
23359
+
23360
+ // src/services/lead/prompt.ts
23361
+ init_src();
23362
+ var bullet2 = (s) => `- ${s}`;
23363
+ function leadPrompt(q, c) {
23364
+ const lines2 = [];
23365
+ lines2.push("Kamu adalah **hanoman-lead**: tech lead mesin di atas semua agen yang bekerja di workspace ini.");
23366
+ lines2.push("Kamu MEMUTUSKAN, lalu melapor. Tidak ada manusia yang menunggu untuk menyetujui jawabanmu.");
23367
+ lines2.push("");
23368
+ lines2.push("## Konteks");
23369
+ lines2.push(bullet2(`Project: ${c.projectName} (${c.projectId})`));
23370
+ if (c.repoDir) lines2.push(bullet2(`Checkout: ${c.repoDir}`));
23371
+ if (c.spec) {
23372
+ lines2.push(bullet2(`Backlog item: ${c.spec.id} \u2014 ${c.spec.title} \xB7 stage ${c.spec.stage} \xB7 prioritas ${c.spec.priority}`));
23373
+ if (c.spec.objective) lines2.push(bullet2(`Objective: ${c.spec.objective}`));
23374
+ }
23375
+ for (const s of c.liveSessions ?? []) {
23376
+ lines2.push(bullet2(`Sesi berjalan: ${s.id}${s.specId ? ` (${s.specId})` : ""}${s.flow ? ` \xB7 flow ${s.flow}` : ""}${s.branch ? ` \xB7 branch ${s.branch}` : ""}`));
23377
+ }
23378
+ for (const n of c.notes ?? []) lines2.push(bullet2(n));
23379
+ lines2.push("");
23380
+ if (c.priorDecisions?.length) {
23381
+ lines2.push("## Keputusan yang sudah kamu ambil sebelumnya (jangan bertentangan tanpa alasan)");
23382
+ for (const d of c.priorDecisions.slice(0, 10)) {
23383
+ lines2.push(`- [${d.createdAt}] "${d.question.slice(0, 200)}" \u2192 ${d.answer.slice(0, 300)} (${d.reason.slice(0, 200)})`);
23384
+ }
23385
+ lines2.push("");
23386
+ }
23387
+ lines2.push("## Yang harus kamu putuskan");
23388
+ lines2.push(q.question.trim());
23389
+ if (q.options?.length) {
23390
+ lines2.push("");
23391
+ lines2.push("Opsi yang dilihat peminta:");
23392
+ for (const [i, o] of q.options.entries()) lines2.push(`${i + 1}. ${o}`);
23393
+ }
23394
+ lines2.push("");
23395
+ lines2.push("## Cara kerja");
23396
+ lines2.push("1. KUMPULKAN BUKTI DULU sebelum memutuskan: `internal/docs/**` (Source of Truth) dan index-nya, ADR yang relevan, plan `docs/superpowers/plans/**`, kode yang bersangkutan, dan riwayat git. Baca, jangan mengingat.");
23397
+ lines2.push('2. Putuskan. Kalau setelah membaca kamu masih ragu, TETAP putuskan: pilih opsi yang PALING MUDAH DIBATALKAN, lalu tandai `confidence: "ragu"`. Jangan pernah menjawab "tidak tahu" atau meminta manusia memutuskan \u2014 itu persis keadaan yang kamu ada untuk menghapusnya.');
23398
+ lines2.push("3. Rujuk bukti yang BENAR-BENAR kamu baca. Rujukan berupa path berkas relatif terhadap checkout, nomor ADR (`ADR-0091`), atau sha commit. Rujukan yang tak ada di repo akan dibuang server dan membuat jawabanmu tampak tanpa dasar.");
23399
+ lines2.push("4. JANGAN membaca atau mengutip kredensial (isi `.env*`, token, kunci privat). Jejak keputusan disimpan di basis data; rahasia tak boleh mendarat di sana.");
23400
+ lines2.push("5. Kamu TIDAK mengeksekusi apa pun sendiri. Kamu mengusulkan satu `action`; server yang menjalankannya, dan hanya bila ia ada di daftar tertutup ini:");
23401
+ lines2.push(` ${LEAD_ACTIONS.join(" \xB7 ")}`);
23402
+ lines2.push(" Deploy, perintah/konsol VPS, data produksi, dan penghapusan apa pun (project, backlog, branch, worktree, notifikasi, jejak) TERKUNCI dan tidak akan pernah dijalankan.");
23403
+ lines2.push("");
23404
+ lines2.push("## Bentuk jawaban (WAJIB)");
23405
+ lines2.push("Akhiri jawabanmu dengan TEPAT SATU blok berikut, tanpa teks sesudahnya:");
23406
+ lines2.push("");
23407
+ lines2.push("```json");
23408
+ lines2.push(JSON.stringify({
23409
+ decision: "keputusan yang dipilih, satu kalimat",
23410
+ reason: "alasannya, menyebut bukti",
23411
+ refs: ["internal/docs/\u2026", "ADR-00xx"],
23412
+ confidence: "tinggi | sedang | ragu",
23413
+ action: "none",
23414
+ reply: "teks yang akan diketikkan ke terminal agen peminta (kosongkan bila tak relevan)"
23415
+ }, null, 2));
23416
+ lines2.push("```");
23417
+ return lines2.join("\n");
23418
+ }
23419
+
23420
+ // src/services/lead/verdict.ts
23421
+ init_src();
23422
+ import { existsSync as existsSync10 } from "node:fs";
23423
+ import { isAbsolute as isAbsolute3, resolve as resolvePath, relative } from "node:path";
23424
+ function extractJsonBlock(raw) {
23425
+ const re = /```json\s*\n([\s\S]*?)```/g;
23426
+ let last = null;
23427
+ for (const m of raw.matchAll(re)) if (m[1]) last = m[1];
23428
+ if (last) return last;
23429
+ const t = raw.trim();
23430
+ return t.startsWith("{") && t.endsWith("}") ? t : null;
23431
+ }
23432
+ function parseLeadVerdict(raw) {
23433
+ const block2 = extractJsonBlock(raw);
23434
+ if (!block2) return null;
23435
+ let data;
23436
+ try {
23437
+ data = JSON.parse(block2);
23438
+ } catch {
23439
+ return null;
23440
+ }
23441
+ const parsed = zLeadVerdict.safeParse(data);
23442
+ return parsed.success ? parsed.data : null;
23443
+ }
23444
+ var SHA = /^[0-9a-f]{7,40}$/i;
23445
+ var isNonFileRef = (ref) => SHA.test(ref) || /^ADR-\d{4}$/i.test(ref);
23446
+ function keepExistingRefs(refs2, repoDir) {
23447
+ const out3 = [];
23448
+ for (const raw of refs2) {
23449
+ const ref = raw.trim();
23450
+ if (!ref) continue;
23451
+ if (isNonFileRef(ref)) {
23452
+ out3.push(ref);
23453
+ continue;
23454
+ }
23455
+ if (!repoDir) continue;
23456
+ if (isAbsolute3(ref)) continue;
23457
+ const full = resolvePath(repoDir, ref);
23458
+ const rel = relative(repoDir, full);
23459
+ if (rel.startsWith("..")) continue;
23460
+ if (existsSync10(full)) out3.push(ref);
23461
+ }
23462
+ return [...new Set(out3)];
23463
+ }
23464
+
23465
+ // src/services/lead/trail.ts
23466
+ init_db();
23467
+ async function recordDecision(i) {
23468
+ return prisma.leadDecision.create({
23469
+ data: {
23470
+ projectId: i.projectId,
23471
+ specId: i.specId ?? null,
23472
+ sessionId: i.sessionId ?? null,
23473
+ gate: i.gate,
23474
+ kind: i.kind,
23475
+ question: i.question,
23476
+ answer: i.answer,
23477
+ reason: i.reason,
23478
+ refs: i.refs,
23479
+ confidence: i.confidence,
23480
+ action: i.action,
23481
+ status: i.status ?? "berlaku",
23482
+ weighty: i.weighty ?? false,
23483
+ actor: i.actor ?? "lead"
23484
+ }
23485
+ });
23486
+ }
23487
+ async function listDecisions(f = {}) {
23488
+ return prisma.leadDecision.findMany({
23489
+ where: {
23490
+ ...f.projectId ? { projectId: f.projectId } : {},
23491
+ ...f.specId ? { specId: f.specId } : {},
23492
+ ...f.sessionId ? { sessionId: f.sessionId } : {},
23493
+ ...f.status ? { status: f.status } : {}
23494
+ },
23495
+ orderBy: { createdAt: "desc" },
23496
+ take: Math.min(f.take ?? 50, 200),
23497
+ skip: f.skip ?? 0
23498
+ });
23499
+ }
23500
+ async function overrideDecision(id, answer, reason) {
23501
+ const old = await prisma.leadDecision.findUnique({ where: { id } });
23502
+ if (!old || old.status !== "berlaku") return null;
23503
+ const next = await recordDecision({
23504
+ projectId: old.projectId,
23505
+ specId: old.specId,
23506
+ sessionId: old.sessionId,
23507
+ gate: old.gate,
23508
+ kind: old.kind,
23509
+ question: old.question,
23510
+ answer,
23511
+ reason: reason || "ditimpa operator",
23512
+ refs: [],
23513
+ confidence: "tinggi",
23514
+ action: "none",
23515
+ actor: "operator"
23516
+ });
23517
+ const updated = await prisma.leadDecision.update({
23518
+ where: { id },
23519
+ data: { status: "ditimpa", supersededById: next.id }
23520
+ });
23521
+ return { old: updated, next };
23522
+ }
23523
+ async function cancelDecision(id) {
23524
+ const row = await prisma.leadDecision.findUnique({ where: { id } });
23525
+ if (!row || row.status !== "berlaku") return null;
23526
+ return prisma.leadDecision.update({ where: { id }, data: { status: "dibatalkan" } });
23527
+ }
23528
+ function toDecisionView(r) {
23529
+ return {
23530
+ id: r.id,
23531
+ projectId: r.projectId,
23532
+ specId: r.specId,
23533
+ sessionId: r.sessionId,
23534
+ gate: r.gate,
23535
+ kind: r.kind,
23536
+ question: r.question,
23537
+ answer: r.answer,
23538
+ reason: r.reason,
23539
+ refs: Array.isArray(r.refs) ? r.refs.map(String) : [],
23540
+ confidence: r.confidence,
23541
+ action: r.action,
23542
+ status: r.status,
23543
+ weighty: r.weighty,
23544
+ supersededById: r.supersededById,
23545
+ createdAt: r.createdAt.toISOString()
23546
+ };
23547
+ }
23548
+
23549
+ // src/services/lead/brain.ts
23550
+ init_config2();
23551
+ import { execFile as execFile9 } from "node:child_process";
23552
+ var binFor = (agent) => agent === "codex" ? effectiveStr("HANOMAN_CODEX_BIN") ?? "codex" : effectiveStr("HANOMAN_CLAUDE_BIN") ?? "claude";
23553
+ function leadArgv(o) {
23554
+ if (o.agent === "codex") {
23555
+ return [
23556
+ "exec",
23557
+ ...o.model ? ["-m", o.model] : [],
23558
+ ...o.effort ? ["-c", `model_reasoning_effort="${o.effort}"`] : [],
23559
+ "--dangerously-bypass-approvals-and-sandbox",
23560
+ o.prompt
23561
+ ];
23562
+ }
23563
+ return [
23564
+ "-p",
23565
+ ...o.model ? ["--model", o.model] : [],
23566
+ ...o.effort ? ["--effort", o.effort] : [],
23567
+ "--dangerously-skip-permissions",
23568
+ o.prompt
23569
+ ];
23570
+ }
23571
+ function think(prompt, o) {
23572
+ const bin = binFor(o.agent);
23573
+ const args = leadArgv({ agent: o.agent, model: o.model, effort: o.effort, prompt });
23574
+ return new Promise((resolve15, reject) => {
23575
+ execFile9(bin, args, {
23576
+ cwd: o.cwd,
23577
+ timeout: o.timeoutMs,
23578
+ maxBuffer: 16 * 1024 * 1024,
23579
+ encoding: "utf8",
23580
+ killSignal: "SIGTERM"
23581
+ }, (err, stdout, stderr) => {
23582
+ if (err) {
23583
+ const killed = err.killed;
23584
+ reject(new Error(killed ? `lead ${o.agent} kehabisan waktu ${o.timeoutMs} ms` : `lead ${o.agent} gagal: ${(stderr || err.message).trim().slice(0, 500)}`));
23585
+ return;
23586
+ }
23587
+ resolve15(stdout);
23588
+ });
23589
+ });
23590
+ }
23591
+
23592
+ // src/services/lead/decide.ts
23593
+ var prodDecideDeps = {
23594
+ think,
23595
+ defaults: leadAgentDefaults,
23596
+ repoDir: resolveRepoDir,
23597
+ liveSessions: () => {
23598
+ try {
23599
+ return listSessions();
23600
+ } catch {
23601
+ return [];
23602
+ }
23603
+ },
23604
+ notify: recordLeadDecision
23605
+ };
23606
+ var notifTitle = (kind, question, answer, confidence) => {
23607
+ const q = question.replace(/\s+/g, " ").trim().slice(0, 70);
23608
+ const a = answer.replace(/\s+/g, " ").trim().slice(0, 90);
23609
+ const tag2 = confidence === "ragu" ? "Lead (ragu)" : "Lead";
23610
+ return `${tag2} \xB7 ${kind}: ${q ? `"${q}" \u2192 ` : ""}${a}`;
23611
+ };
23612
+ async function decide(req, deps = prodDecideDeps) {
23613
+ const cfg = await getLead();
23614
+ if (!leadActive(cfg, req.projectId)) return null;
23615
+ const project = await prisma.project.findUnique({ where: { id: req.projectId }, select: { name: true } });
23616
+ const spec = req.specId ? await prisma.spec.findUnique({
23617
+ where: { id: req.specId },
23618
+ select: { id: true, title: true, objective: true, stage: true, priority: true }
23619
+ }) : null;
23620
+ const repoDir = await deps.repoDir(req.projectId).catch(() => null);
23621
+ const prior = await prisma.leadDecision.findMany({
23622
+ where: { projectId: req.projectId, status: "berlaku" },
23623
+ orderBy: { createdAt: "desc" },
23624
+ take: 10
23625
+ });
23626
+ const ctx = {
23627
+ projectId: req.projectId,
23628
+ projectName: project?.name ?? req.projectId,
23629
+ repoDir,
23630
+ spec,
23631
+ liveSessions: deps.liveSessions().filter((s) => !s.exited && s.projectId === req.projectId && s.id !== req.sessionId).map((s) => ({ id: s.id, specId: s.specId, flow: s.flow, branch: s.branch })),
23632
+ priorDecisions: prior.map((d) => ({
23633
+ question: d.question,
23634
+ answer: d.answer,
23635
+ reason: d.reason,
23636
+ createdAt: d.createdAt.toISOString()
23637
+ })),
23638
+ notes: req.notes
23639
+ };
23640
+ const { agent, model, effort } = await deps.defaults();
23641
+ const prompt = leadPrompt({ kind: req.kind, question: req.question, options: req.options }, ctx);
23642
+ if (req.sessionId) markDeciding(req.sessionId);
23643
+ let raw;
23644
+ try {
23645
+ raw = await deps.think(prompt, { agent, model, effort, cwd: repoDir ?? void 0, timeoutMs: cfg.timeoutSec * 1e3 });
23646
+ } catch (e) {
23647
+ return fail(req, deps, `lead tak menghasilkan keputusan: ${e.message}`);
23648
+ } finally {
23649
+ if (req.sessionId) clearDeciding(req.sessionId);
23650
+ }
23651
+ const verdict = parseLeadVerdict(raw);
23652
+ if (!verdict) return fail(req, deps, "keluaran lead tak memuat blok json keputusan yang sah");
23653
+ const refs2 = keepExistingRefs(verdict.refs, repoDir);
23654
+ const allowed = leadActionAllowed(verdict.action);
23655
+ const action = allowed ? verdict.action : "none";
23656
+ const kind = allowed ? req.kind : "refusal";
23657
+ const reason = allowed ? verdict.reason : `${verdict.reason}
23658
+
23659
+ DITOLAK: ${leadRefusalReason(verdict.action)} berada di luar permukaan tindakan lead (ADR-0091 \xB7 AC-31/32).`;
23660
+ const weighty = isWeightyDecision({ kind, action, confidence: verdict.confidence });
23661
+ const row = await recordDecision({
23662
+ projectId: req.projectId,
23663
+ specId: req.specId,
23664
+ sessionId: req.sessionId,
23665
+ gate: req.gate,
23666
+ kind,
23667
+ question: req.question,
23668
+ answer: verdict.decision,
23669
+ reason,
23670
+ refs: refs2,
23671
+ confidence: verdict.confidence,
23672
+ action,
23673
+ weighty
23674
+ });
23675
+ if (weighty) {
23676
+ await deps.notify(
23677
+ row.id,
23678
+ notifTitle(kind, req.question, verdict.decision, verdict.confidence),
23679
+ req.projectId,
23680
+ req.specId ?? null,
23681
+ req.sessionId ?? null
23682
+ );
23683
+ }
23684
+ lastReply.set(row.id, verdict.reply || verdict.decision);
23685
+ return row;
23686
+ }
23687
+ var lastReply = /* @__PURE__ */ new Map();
23688
+ function takeReply(decisionId) {
23689
+ const v = lastReply.get(decisionId) ?? "";
23690
+ lastReply.delete(decisionId);
23691
+ return v;
23692
+ }
23693
+ async function fail(req, deps, reason) {
23694
+ const row = await recordDecision({
23695
+ projectId: req.projectId,
23696
+ specId: req.specId,
23697
+ sessionId: req.sessionId,
23698
+ gate: req.gate,
23699
+ kind: req.kind,
23700
+ question: req.question,
23701
+ answer: "",
23702
+ reason,
23703
+ refs: [],
23704
+ confidence: "ragu",
23705
+ action: "none",
23706
+ status: "gagal",
23707
+ weighty: true
23708
+ });
23709
+ await deps.notify(
23710
+ row.id,
23711
+ `Lead gagal memutuskan: ${req.question.replace(/\s+/g, " ").slice(0, 90)}`,
23712
+ req.projectId,
23713
+ req.specId ?? null,
23714
+ req.sessionId ?? null
23715
+ );
23716
+ return row;
23717
+ }
23718
+
23719
+ // src/services/lead/apply.ts
23720
+ init_src();
23721
+ init_db();
23722
+ init_pty();
23723
+ init_session_phases();
23724
+ var prodApplyDeps = {
23725
+ killSession: (id) => {
23726
+ try {
23727
+ return killSession(id);
23728
+ } catch {
23729
+ return false;
23730
+ }
23731
+ },
23732
+ sessionExists: (id) => {
23733
+ try {
23734
+ return !!getSession(id);
23735
+ } catch {
23736
+ return false;
23737
+ }
23738
+ },
23739
+ startSpec: startSpecSession,
23740
+ repoDir: resolveRepoDir,
23741
+ planDone: planComplete,
23742
+ integrate,
23743
+ notify: recordLeadDecision
23744
+ };
23745
+ async function applyAction(row, deps = prodApplyDeps) {
23746
+ const action = row.action;
23747
+ if (!leadActionAllowed(action)) {
23748
+ await deps.notify(
23749
+ row.id,
23750
+ `Lead menolak tindakan terkunci: ${leadRefusalReason(action)}`,
23751
+ row.projectId,
23752
+ row.specId,
23753
+ row.sessionId
23754
+ );
23755
+ return { ok: false, detail: `tindakan terkunci: ${leadRefusalReason(action)}` };
23756
+ }
23757
+ switch (action) {
23758
+ // Keputusan yang wujudnya memang hanya baris jejak.
23759
+ case "none":
23760
+ case "answer-session":
23761
+ // sudah diketikkan ke pane oleh pintu deteksi
23762
+ case "order-queue":
23763
+ return { ok: true, detail: "tak ada tindakan tersisa" };
23764
+ // Ditata sebagai keputusan, dieksekusi operator di versi ini (lihat ADR-0091 §Konsekuensi).
23765
+ case "hold-work":
23766
+ case "push-branch":
23767
+ case "run-migration":
23768
+ return { ok: true, detail: `dicatat sebagai keputusan; "${action}" dieksekusi operator di versi ini` };
23769
+ case "stop-session":
23770
+ return stopSession(row, deps);
23771
+ case "start-session":
23772
+ case "resume-session":
23773
+ case "restart-session":
23774
+ return relaunch(row, deps);
23775
+ case "integrate-main":
23776
+ return integrateMain(row, deps);
23777
+ }
23778
+ }
23779
+ function stopSession(row, deps) {
23780
+ if (!row.sessionId) return { ok: false, detail: "keputusan ini tak menunjuk sesi" };
23781
+ const killed = deps.killSession(row.sessionId);
23782
+ return killed ? { ok: true, detail: `sesi ${row.sessionId} dihentikan; worktree dibiarkan utuh (AC-32a)` } : { ok: false, detail: `sesi ${row.sessionId} tak ada` };
23783
+ }
23784
+ async function relaunch(row, deps) {
23785
+ if (!row.specId) return { ok: false, detail: "keputusan ini tak menunjuk backlog" };
23786
+ const spec = await prisma.spec.findUnique({ where: { id: row.specId } });
23787
+ if (!spec) return { ok: false, detail: `backlog ${row.specId} tak ada` };
23788
+ const sid = sessionIdForSpec(spec.id);
23789
+ if (deps.sessionExists(sid)) deps.killSession(sid);
23790
+ const r = await deps.startSpec(spec, { flow: flowForSource(spec.source) });
23791
+ return { ok: true, detail: `sesi ${r.id}${r.resumed ? " dilanjutkan" : " dimulai"}` };
23792
+ }
23793
+ async function integrateMain(row, deps) {
23794
+ if (!row.specId) return { ok: false, detail: "keputusan ini tak menunjuk backlog" };
23795
+ const cfg = await getLead();
23796
+ const repoDir = await deps.repoDir(row.projectId);
23797
+ if (!repoDir) return { ok: false, detail: `project ${row.projectId} belum di-bind ke checkout lokal` };
23798
+ const spec = await prisma.spec.findUnique({ where: { id: row.specId } });
23799
+ if (!spec) return { ok: false, detail: `backlog ${row.specId} tak ada` };
23800
+ const evidence = [];
23801
+ if (cfg.requireGreenBeforeIntegrate) {
23802
+ const wt = worktreeDir(repoDir, spec.id);
23803
+ const done = deps.planDone(wt, spec.id);
23804
+ evidence.push(done ? "plan tak menyisakan `- [ ]`" : "plan MASIH menyisakan `- [ ]`");
23805
+ const sid = sessionIdForSpec(spec.id);
23806
+ const pane = deps.sessionExists(sid);
23807
+ evidence.push(pane ? `pane ${sid} masih ada` : `pane ${sid} sudah tak ada`);
23808
+ if (!done) {
23809
+ await recordEvidence(row, evidence, "integrasi DIBATALKAN \u2014 syarat objektif tak terpenuhi");
23810
+ await deps.notify(
23811
+ row.id,
23812
+ `Lead membatalkan integrasi ${spec.id}: plan belum tuntas`,
23813
+ row.projectId,
23814
+ row.specId,
23815
+ row.sessionId
23816
+ );
23817
+ return { ok: false, detail: "syarat integrasi tak terpenuhi: plan masih menyisakan `- [ ]`" };
23818
+ }
23819
+ } else {
23820
+ evidence.push("syarat objektif dimatikan operator (lead.requireGreenBeforeIntegrate = false)");
23821
+ }
23822
+ const res = await deps.integrate(repoDir, spec.id, "merge", "local:main");
23823
+ evidence.push(`integrate \u2192 ${res.status}`);
23824
+ await recordEvidence(row, evidence, res.status === "clean" ? "integrasi bersih" : `integrasi tak bersih: ${res.status}`);
23825
+ if (res.status !== "clean") {
23826
+ await deps.notify(
23827
+ row.id,
23828
+ `Integrasi ${spec.id} oleh lead tak bersih (${res.status}) \u2014 butuh operator`,
23829
+ row.projectId,
23830
+ row.specId,
23831
+ row.sessionId
23832
+ );
23833
+ }
23834
+ return { ok: res.status === "clean", detail: evidence.join("; ") };
23835
+ }
23836
+ async function recordEvidence(row, evidence, verdict) {
23837
+ await prisma.leadDecision.update({
23838
+ where: { id: row.id },
23839
+ data: { reason: `${row.reason}
23840
+
23841
+ Bukti integrasi: ${evidence.join("; ")} \u2192 ${verdict}.` }
23842
+ }).catch(() => {
23843
+ });
23844
+ }
23845
+
23846
+ // src/services/lead/detect.ts
23847
+ init_pty();
23848
+
23849
+ // src/services/lead/pane.ts
23850
+ var CLEAN = /[│┃┆┊┌┐└┘├┤┬┴┼─━╭╮╰╯>❯]/g;
23851
+ var tail = (text, lines2) => text.split("\n").map((l) => l.replace(CLEAN, " ").trimEnd()).filter((l) => l.trim()).slice(-lines2);
23852
+ var CODEX_FINISHED = [
23853
+ /Goal achieved/i,
23854
+ /Goal unmet/i,
23855
+ /\btokens used\b/i,
23856
+ /\bTo continue this session\b/i
23857
+ ];
23858
+ var ASK_SIGNALS = [
23859
+ /\?\s*$/m,
23860
+ /^\s*\[?\d+[).\]]\s+\S/m,
23861
+ /\b(pilih|apakah|haruskah|mana yang|opsi|which|should i|do you want|proceed\?)\b/i
23862
+ ];
23863
+ function readPaneQuestion(text, agent) {
23864
+ const lines2 = tail(text, 40);
23865
+ const body = lines2.join("\n").trim();
23866
+ if (!body) return { asking: false, question: "", reason: "layar kosong" };
23867
+ const question = tail(text, 25).join("\n").trim();
23868
+ if (agent === "codex") {
23869
+ const finished = CODEX_FINISHED.find((re) => re.test(body));
23870
+ if (finished) return { asking: false, question, reason: "sesi codex selesai wajar (ADR-0074)" };
23871
+ if (!ASK_SIGNALS.some((re) => re.test(body)))
23872
+ return { asking: false, question, reason: "tak ada sinyal pertanyaan di layar codex" };
23873
+ }
23874
+ return { asking: true, question, reason: "" };
23875
+ }
23876
+
23877
+ // src/services/lead/detect.ts
23878
+ var answers = /* @__PURE__ */ new Map();
23879
+ var capped = /* @__PURE__ */ new Set();
23880
+ function resetSession(sessionId2) {
23881
+ answers.delete(sessionId2);
23882
+ capped.delete(sessionId2);
23883
+ }
23884
+ var prodDetectDeps = {
23885
+ live: () => {
23886
+ try {
23887
+ return liveDecisions();
23888
+ } catch {
23889
+ return [];
23890
+ }
23891
+ },
23892
+ filled: markerFilled,
23893
+ pane: (id) => capturePane(id),
23894
+ agentOf: (id) => {
23895
+ try {
23896
+ return getSession(id)?.agent ?? null;
23897
+ } catch {
23898
+ return null;
23899
+ }
23900
+ },
23901
+ // SPEC-402 · tmux tak terbaca ≠ pane mati. Ragu → perlakukan sebagai mati: yang hilang cuma satu
23902
+ // jawaban otomatis (sesi jatuh ke perilaku hari ini), sementara salah arah membuat lead mengetik
23903
+ // ke pane yang sudah tak ada.
23904
+ exited: (id) => {
23905
+ try {
23906
+ return getSession(id)?.exited ?? true;
23907
+ } catch {
23908
+ return true;
23909
+ }
23910
+ },
23911
+ send: (id, text) => sendToPane(id, text),
23912
+ decide,
23913
+ decideDeps: prodDecideDeps,
23914
+ optIn: leadProjects,
23915
+ notify: recordLeadDecision,
23916
+ cfg: getLead
23917
+ };
23918
+ async function scanAndAnswer(deps = prodDetectDeps) {
23919
+ const out3 = { answered: [], skipped: [] };
23920
+ const cfg = await deps.cfg();
23921
+ if (!cfg.enabled || cfg.paused) return out3;
23922
+ const optIn = new Set(await deps.optIn());
23923
+ const sessions = deps.live();
23924
+ sweep(sessions.map((s) => s.id));
23925
+ for (const s of sessions) {
23926
+ const skip = (reason) => out3.skipped.push({ id: s.id, reason });
23927
+ if (!optIn.has(s.projectId)) {
23928
+ skip("project tak opt-in lead");
23929
+ continue;
23930
+ }
23931
+ if (!leadActive(cfg, s.projectId)) {
23932
+ skip("lead dijeda untuk project ini");
23933
+ continue;
23934
+ }
23935
+ if (!deps.filled(s.decisionFile)) continue;
23936
+ if (deps.exited(s.id)) {
23937
+ skip("pane mati");
23938
+ continue;
23939
+ }
23940
+ if ((answers.get(s.id) ?? 0) >= cfg.maxAutoAnswers) {
23941
+ if (!capped.has(s.id)) {
23942
+ capped.add(s.id);
23943
+ const row2 = await recordDecision({
23944
+ projectId: s.projectId,
23945
+ specId: s.specId,
23946
+ sessionId: s.id,
23947
+ gate: "detected",
23948
+ kind: "quality",
23949
+ question: `Sesi ${s.id} sudah dijawab otomatis ${cfg.maxAutoAnswers}\xD7 berturut-turut.`,
23950
+ answer: "Berhenti menjawab sesi ini; serahkan ke operator.",
23951
+ reason: "Batas jawaban otomatis per sesi tercapai \u2014 pengulangan menandakan lead tak benar-benar membuka jalan buntunya (AC-11).",
23952
+ refs: [],
23953
+ confidence: "tinggi",
23954
+ action: "none",
23955
+ weighty: true
23956
+ });
23957
+ await deps.notify(
23958
+ row2.id,
23959
+ `Lead berhenti menjawab sesi ${s.id} (batas ${cfg.maxAutoAnswers}\xD7 tercapai)`,
23960
+ s.projectId,
23961
+ s.specId ?? null,
23962
+ s.id
23963
+ );
23964
+ }
23965
+ skip("batas jawaban otomatis tercapai");
23966
+ continue;
23967
+ }
23968
+ const agent = deps.agentOf(s.id) ?? "claude";
23969
+ const read = readPaneQuestion(deps.pane(s.id), agent);
23970
+ if (!read.asking) {
23971
+ skip(read.reason);
23972
+ continue;
23973
+ }
23974
+ const row = await deps.decide({
23975
+ projectId: s.projectId,
23976
+ specId: s.specId,
23977
+ sessionId: s.id,
23978
+ gate: "detected",
23979
+ kind: "answer",
23980
+ question: read.question,
23981
+ notes: [`Sesi ini menunggu di terminal; teks di bawah adalah layar terakhirnya. Jawablah sebagai masukan yang bisa langsung diketik ke terminal itu (isi \`reply\`).`]
23982
+ }, deps.decideDeps);
23983
+ if (!row || row.status !== "berlaku") {
23984
+ skip("lead tak menghasilkan keputusan yang berlaku");
23985
+ continue;
23986
+ }
23987
+ const reply = takeReply(row.id) || row.answer;
23988
+ const sent = await deps.send(s.id, reply);
23989
+ if (!sent) {
23990
+ skip("gagal mengetik ke pane");
23991
+ continue;
23992
+ }
23993
+ answers.set(s.id, (answers.get(s.id) ?? 0) + 1);
23994
+ out3.answered.push(s.id);
23995
+ }
23996
+ return out3;
23997
+ }
23998
+ function sweep(liveIds) {
23999
+ const live = new Set(liveIds);
24000
+ for (const id of [...answers.keys()]) if (!live.has(id)) answers.delete(id);
24001
+ for (const id of [...capped]) if (!live.has(id)) capped.delete(id);
24002
+ }
24003
+
24004
+ // src/services/lead/pulse.ts
24005
+ init_db();
24006
+ init_pty();
24007
+ init_session_phases();
24008
+ var moduleOf = (p) => p.split("/").slice(0, 2).join("/");
24009
+ function findCollisions(areas) {
24010
+ const out3 = [];
24011
+ for (let i = 0; i < areas.length; i++) {
24012
+ for (let j = i + 1; j < areas.length; j++) {
24013
+ const a = areas[i], b = areas[j];
24014
+ if (a.projectId !== b.projectId) continue;
24015
+ const bp = new Set(b.paths);
24016
+ const shared = a.paths.filter((p) => bp.has(p));
24017
+ const bm = new Set(b.paths.map(moduleOf));
24018
+ const nearby = [...new Set(a.paths.map(moduleOf))].filter((m) => bm.has(m) && !shared.some((s) => moduleOf(s) === m));
24019
+ if (shared.length || nearby.length) out3.push({ a, b, shared, nearby });
24020
+ }
24021
+ }
24022
+ return out3;
24023
+ }
24024
+ var prodPulseDeps = {
24025
+ sessions: () => {
24026
+ try {
24027
+ return listSessions();
24028
+ } catch {
24029
+ return [];
24030
+ }
24031
+ },
24032
+ areas: async (s) => {
24033
+ const repoDir = await resolveRepoDir(s.projectId);
24034
+ if (!repoDir) return [];
24035
+ const spec = await prisma.spec.findUnique({ where: { id: s.specId }, select: { baseSha: true, branchFrom: true } });
24036
+ if (!spec) return [];
24037
+ try {
24038
+ const r = await specReview(repoDir, s.specId, spec.baseSha, spec.branchFrom);
24039
+ return r.changed.map((c) => c.path);
24040
+ } catch {
24041
+ return [];
24042
+ }
24043
+ },
24044
+ planDone: planComplete,
24045
+ decide,
24046
+ decideDeps: prodDecideDeps,
24047
+ apply: applyAction,
24048
+ enqueue,
24049
+ notify: recordLeadDecision,
24050
+ optIn: leadProjects,
24051
+ cfg: getLead
24052
+ };
24053
+ var lastReadySig = /* @__PURE__ */ new Map();
24054
+ async function pulse(deps = prodPulseDeps) {
24055
+ const res = { ordered: 0, collisions: 0, quality: 0 };
24056
+ const cfg = await deps.cfg();
24057
+ if (!cfg.enabled || cfg.paused) return res;
24058
+ const optIn = (await deps.optIn()).filter((p) => leadActive(cfg, p));
24059
+ if (!optIn.length) return res;
24060
+ try {
24061
+ res.quality = await followUpFinished(cfg, optIn, deps);
24062
+ } catch {
24063
+ }
24064
+ try {
24065
+ res.collisions = await detectCollisions(optIn, deps);
24066
+ } catch {
24067
+ }
24068
+ try {
24069
+ res.ordered = await orderReadyWork(optIn, deps);
24070
+ } catch {
24071
+ }
24072
+ return res;
24073
+ }
24074
+ async function followUpFinished(cfg, optIn, deps) {
24075
+ let n = 0;
24076
+ const opt = new Set(optIn);
24077
+ for (const s of deps.sessions()) {
24078
+ if (!s.exited || !s.specId || !opt.has(s.projectId)) continue;
24079
+ const bad = (s.exitCode ?? 0) !== 0;
24080
+ const unfinished = !deps.planDone(s.cwd, s.specId);
24081
+ if (!bad && !unfinished) continue;
24082
+ const seen = await prisma.leadDecision.findFirst({ where: { sessionId: s.id, kind: "quality", gate: "pulse" } });
24083
+ if (seen) continue;
24084
+ const why = [
24085
+ bad ? `berakhir dengan kode keluar ${s.exitCode}` : null,
24086
+ unfinished ? "plan-nya masih menyisakan kotak `- [ ]`" : null
24087
+ ].filter(Boolean).join(" dan ");
24088
+ const row = await deps.decide({
24089
+ projectId: s.projectId,
24090
+ specId: s.specId,
24091
+ sessionId: s.id,
24092
+ gate: "pulse",
24093
+ kind: "quality",
24094
+ question: `Sesi ${s.id} untuk backlog ${s.specId} ${why}. Tindak lanjutnya apa: lanjutkan pekerjaan yang terputus, ulangi dari awal, atau hentikan?`,
24095
+ options: [
24096
+ "resume-session \u2014 lanjutkan dari keadaan worktree sekarang (ADR-0084)",
24097
+ "restart-session \u2014 ulangi dari awal",
24098
+ "none \u2014 terima apa adanya, sertakan alasannya"
24099
+ ],
24100
+ notes: [`Worktree sesi: ${s.cwd}`]
24101
+ }, deps.decideDeps);
24102
+ if (!row) continue;
24103
+ n++;
24104
+ if (row.status === "berlaku" && row.action !== "none") {
24105
+ try {
24106
+ await deps.apply(row);
24107
+ } catch {
24108
+ }
24109
+ }
24110
+ }
24111
+ return n;
24112
+ }
24113
+ async function detectCollisions(optIn, deps) {
24114
+ const opt = new Set(optIn);
24115
+ const live = deps.sessions().filter((s) => !s.exited && s.specId && opt.has(s.projectId));
24116
+ if (live.length < 2) return 0;
24117
+ const areas = [];
24118
+ for (const s of live) {
24119
+ const paths2 = await deps.areas({ id: s.id, projectId: s.projectId, specId: s.specId });
24120
+ if (paths2.length) areas.push({ specId: s.specId, sessionId: s.id, projectId: s.projectId, paths: paths2 });
24121
+ }
24122
+ let n = 0;
24123
+ for (const c of findCollisions(areas)) {
24124
+ const key = [c.a.sessionId, c.b.sessionId].sort().join("|");
24125
+ const seen = await prisma.leadDecision.findFirst({
24126
+ where: { kind: "collision", gate: "pulse", question: { contains: key } }
24127
+ });
24128
+ if (seen) continue;
24129
+ const row = await deps.decide({
24130
+ projectId: c.a.projectId,
24131
+ specId: c.a.specId,
24132
+ sessionId: c.a.sessionId,
24133
+ gate: "pulse",
24134
+ kind: "collision",
24135
+ question: `Dua pekerjaan menyentuh area yang sama [${key}]: ${c.a.specId} dan ${c.b.specId}. Tunda salah satu, gabungkan, atau biarkan?`,
24136
+ options: [
24137
+ `hold-work \u2014 tunda salah satu sampai yang lain terintegrasi`,
24138
+ `none \u2014 biarkan berjalan, sertakan alasan kenapa tabrakan ini aman`
24139
+ ],
24140
+ notes: [
24141
+ c.shared.length ? `Berkas yang sama: ${c.shared.slice(0, 20).join(", ")}` : "",
24142
+ c.nearby.length ? `Modul yang sama: ${c.nearby.slice(0, 20).join(", ")}` : ""
24143
+ ].filter(Boolean)
24144
+ }, deps.decideDeps);
24145
+ if (row) n++;
24146
+ }
24147
+ return n;
24148
+ }
24149
+ async function orderReadyWork(optIn, deps) {
24150
+ let total = 0;
24151
+ for (const projectId of optIn) total += await orderProject(projectId, deps);
24152
+ return total;
24153
+ }
24154
+ async function orderProject(projectId, deps) {
24155
+ const ready = await prisma.spec.findMany({
24156
+ where: { baseSha: null, projectId },
24157
+ select: { id: true, projectId: true, title: true, priority: true, objective: true },
24158
+ orderBy: { id: "asc" }
24159
+ });
24160
+ if (ready.length < 2) return 0;
24161
+ const sig = ready.map((r) => r.id).join(",");
24162
+ if (sig === lastReadySig.get(projectId)) return 0;
24163
+ lastReadySig.set(projectId, sig);
24164
+ const row = await deps.decide({
24165
+ projectId,
24166
+ gate: "pulse",
24167
+ kind: "order",
24168
+ question: `Ada ${ready.length} backlog siap dikerjakan. Urutkan mana yang lebih dulu berdasarkan isi pekerjaannya, lalu tuliskan urutan id-nya (dipisah koma) di \`decision\`.`,
24169
+ options: ready.map((r) => `${r.id} \xB7 [${r.priority}] ${r.title}`),
24170
+ notes: ready.map((r) => `${r.id}: ${r.objective.slice(0, 200)}`)
24171
+ }, deps.decideDeps);
24172
+ if (!row || row.status !== "berlaku") return 0;
24173
+ const byId2 = new Map(ready.map((r) => [r.id.toLowerCase(), r]));
24174
+ const named = [];
24175
+ for (const tok of row.answer.split(/[,\s]+/).map((t) => t.trim().toLowerCase()).filter(Boolean)) {
24176
+ const hit = byId2.get(tok);
24177
+ if (hit && !named.includes(hit)) named.push(hit);
24178
+ }
24179
+ const ordered = [...named, ...ready.filter((r) => !named.includes(r))];
24180
+ for (const r of ordered) {
24181
+ await deps.enqueue({ specId: r.id, projectId: r.projectId, source: "lead", priority: r.priority });
24182
+ }
24183
+ await deps.notify(row.id, `Lead menata ${ordered.length} backlog siap kerja`, projectId, null, null);
24184
+ return ordered.length;
24185
+ }
24186
+
24187
+ // src/services/lead/engine.ts
24188
+ var TICK_MS = 5e3;
24189
+ var lastPulseAt = 0;
24190
+ var busy2 = false;
24191
+ var timer3;
24192
+ function lastPulse() {
24193
+ return lastPulseAt;
24194
+ }
24195
+ async function tick2(now, deps = {}) {
24196
+ if (busy2) return;
24197
+ busy2 = true;
24198
+ try {
24199
+ const cfg = await getLead();
24200
+ if (!cfg.enabled) return;
24201
+ try {
24202
+ await scanAndAnswer(deps.detect ?? prodDetectDeps);
24203
+ } catch (e) {
24204
+ console.error("lead detect:", e);
24205
+ }
24206
+ if (cfg.paused) return;
24207
+ if (now - lastPulseAt < cfg.everyMin * 6e4) return;
24208
+ lastPulseAt = now;
24209
+ try {
24210
+ await pulse(deps.pulse ?? prodPulseDeps);
24211
+ } catch (e) {
24212
+ console.error("lead pulse:", e);
24213
+ }
24214
+ } catch (e) {
24215
+ console.error("lead tick:", e);
24216
+ } finally {
24217
+ busy2 = false;
24218
+ }
24219
+ }
24220
+ function startLead(deps = {}) {
24221
+ if (timer3) return;
24222
+ timer3 = setInterval(() => void tick2(Date.now(), deps), TICK_MS);
24223
+ timer3.unref();
24224
+ void tick2(Date.now(), deps);
24225
+ }
24226
+
24227
+ // src/routes/lead.ts
24228
+ async function lead_default(app2) {
24229
+ app2.get("/lead/config", async () => getLead());
24230
+ app2.put("/lead/config", async (req, reply) => {
24231
+ const parsed = zLead.safeParse(req.body);
24232
+ if (!parsed.success) return reply.code(400).send({ error: parsed.error.flatten() });
24233
+ return setLead(parsed.data);
24234
+ });
24235
+ app2.get("/lead/status", async () => {
24236
+ const cfg = await getLead();
24237
+ const projects = await prisma.project.findMany({
24238
+ where: { leadOptIn: true },
24239
+ select: { id: true, name: true },
24240
+ orderBy: { id: "asc" }
24241
+ });
24242
+ const since = new Date(Date.now() - 24 * 60 * 60 * 1e3);
24243
+ let live = [];
24244
+ try {
24245
+ live = listSessions().filter((s) => !s.exited);
24246
+ } catch {
24247
+ }
24248
+ let waiting = [];
24249
+ try {
24250
+ waiting = liveDecisions().filter((d) => markerFilled(d.decisionFile)).map((d) => d.id);
24251
+ } catch {
24252
+ }
24253
+ const rows = await Promise.all(projects.map(async (p) => ({
24254
+ projectId: p.id,
24255
+ name: p.name,
24256
+ optIn: true,
24257
+ paused: !leadActive(cfg, p.id),
24258
+ decisions24h: await prisma.leadDecision.count({ where: { projectId: p.id, createdAt: { gte: since } } }),
24259
+ openSessions: live.filter((s) => s.projectId === p.id).length
24260
+ })));
24261
+ const last = lastPulse();
24262
+ return {
24263
+ config: cfg,
24264
+ projects: rows,
24265
+ queue: (await listQueue()).map((q) => ({
24266
+ id: q.id,
24267
+ specId: q.specId,
24268
+ projectId: q.projectId,
24269
+ source: q.source,
24270
+ priority: q.priority,
24271
+ status: q.status,
24272
+ sessionId: q.sessionId,
24273
+ note: q.note,
24274
+ enqueuedAt: q.enqueuedAt.toISOString(),
24275
+ launchedAt: q.launchedAt ? q.launchedAt.toISOString() : null
24276
+ })),
24277
+ deciding: decidingIds(),
24278
+ waiting,
24279
+ lastPulseAt: last ? new Date(last).toISOString() : null
24280
+ };
24281
+ });
24282
+ app2.get("/lead/decisions", async (req) => {
24283
+ const q = req.query;
24284
+ const rows = await listDecisions({
24285
+ projectId: q.projectId,
24286
+ specId: q.specId,
24287
+ sessionId: q.sessionId,
24288
+ status: q.status,
24289
+ take: q.take ? Number(q.take) : void 0,
24290
+ skip: q.skip ? Number(q.skip) : void 0
24291
+ });
24292
+ return { items: rows.map(toDecisionView) };
24293
+ });
24294
+ app2.post("/lead/decisions", async (req, reply) => {
24295
+ const parsed = zLeadAsk.safeParse(req.body);
24296
+ if (!parsed.success) return reply.code(400).send({ error: parsed.error.flatten() });
24297
+ const ask = parsed.data;
24298
+ const project = await prisma.project.findUnique({ where: { id: ask.projectId }, select: { leadOptIn: true } });
24299
+ if (!project) return reply.code(404).send({ error: "project not found" });
24300
+ const cfg = await getLead();
24301
+ if (!project.leadOptIn || !leadActive(cfg, ask.projectId))
24302
+ return reply.code(409).send({ error: "lead tidak aktif untuk project ini" });
24303
+ const row = await decide({
24304
+ projectId: ask.projectId,
24305
+ specId: ask.specId ?? null,
24306
+ sessionId: ask.sessionId ?? null,
24307
+ gate: "contract",
24308
+ kind: "answer",
24309
+ question: ask.question,
24310
+ options: ask.options,
24311
+ notes: ask.context ? [ask.context] : void 0
24312
+ });
24313
+ if (!row) return reply.code(409).send({ error: "lead tidak aktif untuk project ini" });
24314
+ takeReply(row.id);
24315
+ if (row.status === "gagal") return reply.code(504).send({ error: row.reason, id: row.id });
24316
+ if (row.action !== "none") {
24317
+ try {
24318
+ await applyAction(row);
24319
+ } catch {
24320
+ }
24321
+ }
24322
+ const answer = {
24323
+ id: row.id,
24324
+ decision: row.answer,
24325
+ reason: row.reason,
24326
+ refs: Array.isArray(row.refs) ? row.refs.map(String) : [],
24327
+ confidence: row.confidence,
24328
+ action: row.action
24329
+ };
24330
+ return reply.code(201).send(answer);
24331
+ });
24332
+ app2.post("/lead/decisions/:id/override", async (req, reply) => {
24333
+ const { id } = req.params;
24334
+ const parsed = zLeadOverride.safeParse(req.body);
24335
+ if (!parsed.success) return reply.code(400).send({ error: parsed.error.flatten() });
24336
+ const r = await overrideDecision(id, parsed.data.answer, parsed.data.reason);
24337
+ if (!r) return reply.code(409).send({ error: "keputusan tak ada atau sudah tak berlaku" });
24338
+ let delivered = false;
24339
+ if (r.next.sessionId) {
24340
+ resetSession(r.next.sessionId);
24341
+ delivered = await sendToPane(r.next.sessionId, parsed.data.answer).catch(() => false);
24342
+ }
24343
+ return { old: toDecisionView(r.old), next: toDecisionView(r.next), delivered };
24344
+ });
24345
+ app2.post("/lead/decisions/:id/cancel", async (req, reply) => {
24346
+ const { id } = req.params;
24347
+ const row = await cancelDecision(id);
24348
+ if (!row) return reply.code(409).send({ error: "keputusan tak ada atau sudah tak berlaku" });
24349
+ if (row.sessionId) resetSession(row.sessionId);
24350
+ return toDecisionView(row);
24351
+ });
24352
+ }
24353
+
23049
24354
  // src/app.ts
23050
24355
  var import_multipart = __toESM(require_multipart2(), 1);
23051
24356
 
@@ -23329,6 +24634,7 @@ function capabilityForRoute(method, path) {
23329
24634
  if (top === "limits" || top === "update" || top === "events" || top === "fs" || top === "health")
23330
24635
  return read ? "GLOBAL_READ" : "COOKIE_ONLY";
23331
24636
  if (top === "scheduler") return rw("settings");
24637
+ if (top === "lead") return rw("lead");
23332
24638
  if (top === "settings" || top === "config") return rw("settings");
23333
24639
  if (top === "specs") return rw("backlog");
23334
24640
  if (top === "notifications") return rw("notifications");
@@ -23439,9 +24745,10 @@ function buildApp({ requireAuth = true } = {}) {
23439
24745
  await api.register(scheduler_default);
23440
24746
  await api.register(audit_default);
23441
24747
  await api.register(codex);
24748
+ await api.register(lead_default);
23442
24749
  }, { prefix: "/api" });
23443
24750
  if (process.env.NODE_ENV === "production") {
23444
- const dist = pickWebDir(dirname12(fileURLToPath4(import.meta.url)), process.env, existsSync10);
24751
+ const dist = pickWebDir(dirname12(fileURLToPath4(import.meta.url)), process.env, existsSync11);
23445
24752
  if (dist) {
23446
24753
  app2.register(fastifyStatic, { root: dist });
23447
24754
  app2.setNotFoundHandler((req, reply) => req.url.startsWith("/api") ? reply.code(404).send({ error: "not found" }) : reply.sendFile("index.html"));
@@ -23588,7 +24895,7 @@ var prodEnd = {
23588
24895
  reconcile: () => reconcile(reconcileProdDeps),
23589
24896
  scanDecisions: () => scanDecisions()
23590
24897
  };
23591
- async function tick2(now, deps, end2 = prodEnd) {
24898
+ async function tick3(now, deps, end2 = prodEnd) {
23592
24899
  const cfg = await getScheduler();
23593
24900
  if (!cfg.enabled) return;
23594
24901
  for (const src of listSources()) {
@@ -23629,13 +24936,13 @@ var prodDeps = {
23629
24936
  return r.id;
23630
24937
  }
23631
24938
  };
23632
- var TICK_MS = 1e4;
23633
- var timer3;
24939
+ var TICK_MS2 = 1e4;
24940
+ var timer4;
23634
24941
  function startScheduler(deps = prodDeps) {
23635
- if (timer3) return;
23636
- timer3 = setInterval(() => void tick2(Date.now(), deps), TICK_MS);
23637
- timer3.unref();
23638
- void tick2(Date.now(), deps);
24942
+ if (timer4) return;
24943
+ timer4 = setInterval(() => void tick3(Date.now(), deps), TICK_MS2);
24944
+ timer4.unref();
24945
+ void tick3(Date.now(), deps);
23639
24946
  }
23640
24947
 
23641
24948
  // src/services/scheduler/sources/backlog.ts
@@ -23746,6 +25053,7 @@ app.listen({ port, host }).then(() => {
23746
25053
  registerErrorsSource();
23747
25054
  registerTriaseSource();
23748
25055
  startScheduler();
25056
+ startLead();
23749
25057
  void (async () => {
23750
25058
  const { loadConfig: loadConfig2 } = await Promise.resolve().then(() => (init_config2(), config_exports));
23751
25059
  const { applyConfigOnBoot: applyConfigOnBoot2 } = await Promise.resolve().then(() => (init_config_apply(), config_apply_exports));