hanoman 0.1.12 → 0.1.14

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
@@ -4269,6 +4269,39 @@ var init_telegram = __esm({
4269
4269
  }
4270
4270
  });
4271
4271
 
4272
+ // ../shared/src/auto-merge.ts
4273
+ function autoMergeOf(raw) {
4274
+ if (raw === null || raw === void 0) return null;
4275
+ const p = zAutoMerge.safeParse(raw);
4276
+ return p.success ? p.data : null;
4277
+ }
4278
+ function resolveAutoMerge(projectRaw, specRaw) {
4279
+ return autoMergeOf(specRaw) ?? autoMergeOf(projectRaw) ?? AUTO_MERGE_OFF;
4280
+ }
4281
+ function autoMergeTargetOf(p, defaultBranch2) {
4282
+ if (p.mode === "off") return null;
4283
+ const name2 = p.mode === "branch" ? p.branch : defaultBranch2;
4284
+ return name2 ? `${p.dest}:${name2}` : null;
4285
+ }
4286
+ var AUTO_MERGE_MODES, zAutoMerge, AUTO_MERGE_OFF;
4287
+ var init_auto_merge = __esm({
4288
+ "../shared/src/auto-merge.ts"() {
4289
+ "use strict";
4290
+ init_zod();
4291
+ AUTO_MERGE_MODES = ["off", "default-branch", "branch"];
4292
+ zAutoMerge = external_exports.object({
4293
+ mode: external_exports.enum(AUTO_MERGE_MODES).default("off"),
4294
+ // Tujuan: `local` = perbarui ref/fast-forward di checkout ini; `origin` = push.
4295
+ dest: external_exports.enum(["local", "origin"]).default("local"),
4296
+ // Hanya bermakna saat mode = "branch". Untuk "default-branch" ia diresolve SAAT EKSEKUSI.
4297
+ branch: external_exports.string().min(1).nullable().default(null),
4298
+ // Hapus `hanoman/<spec>` (lokal + origin) — HANYA sesudah merge terbukti bersih.
4299
+ deleteBranch: external_exports.boolean().default(false)
4300
+ });
4301
+ AUTO_MERGE_OFF = { mode: "off", dest: "local", branch: null, deleteBranch: false };
4302
+ }
4303
+ });
4304
+
4272
4305
  // ../shared/src/entities.ts
4273
4306
  function codexModel(id) {
4274
4307
  return CODEX_MODELS.find((m) => m.id === id);
@@ -4287,13 +4320,14 @@ function cmpVersion(a, b) {
4287
4320
  }
4288
4321
  return 0;
4289
4322
  }
4290
- var zProject, zBriefPayload, zQaPayload, zGoalPayload, zSpecBlocker, 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;
4323
+ var zProject, zBriefPayload, zQaPayload, zGoalPayload, zSpecBlocker, zSpec, NOTIFY_SOUNDS, MODELS, 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;
4291
4324
  var init_entities = __esm({
4292
4325
  "../shared/src/entities.ts"() {
4293
4326
  "use strict";
4294
4327
  init_zod();
4295
4328
  init_enums();
4296
4329
  init_telegram();
4330
+ init_auto_merge();
4297
4331
  zProject = external_exports.object({
4298
4332
  id: external_exports.string(),
4299
4333
  name: external_exports.string(),
@@ -4359,7 +4393,10 @@ var init_entities = __esm({
4359
4393
  dependsOn: external_exports.array(external_exports.string()).default([]),
4360
4394
  // Turunan (bukan kolom): dihitung `liveSpecs` dari stage dependency + git. Klien tak pernah
4361
4395
  // mengirimkannya — `.default([])` supaya bentuk lama tetap parse.
4362
- blockedBy: external_exports.array(zSpecBlocker).default([])
4396
+ blockedBy: external_exports.array(zSpecBlocker).default([]),
4397
+ // SPEC-486 · ADR-0103 · override kebijakan auto-merge item ini; null = warisi project.
4398
+ // `.nullable().default(null)` menjaga respons/klien versi lama tetap parse.
4399
+ autoMerge: zAutoMerge.nullable().default(null)
4363
4400
  });
4364
4401
  NOTIFY_SOUNDS = [
4365
4402
  "off",
@@ -4377,6 +4414,12 @@ var init_entities = __esm({
4377
4414
  "marimba",
4378
4415
  "fanfare"
4379
4416
  ];
4417
+ MODELS = [
4418
+ { id: "claude-opus-5", label: "Opus 5" },
4419
+ { id: "claude-sonnet-5", label: "Sonnet 5" },
4420
+ { id: "claude-haiku-4-5", label: "Haiku 4.5" },
4421
+ { id: "claude-fable-5", label: "Fable 5" }
4422
+ ];
4380
4423
  E_5_6 = ["ultra", "max", "xhigh", "high", "medium", "low"];
4381
4424
  E_LUNA = ["max", "xhigh", "high", "medium", "low"];
4382
4425
  E_BASE = ["xhigh", "high", "medium", "low"];
@@ -4460,6 +4503,11 @@ var init_entities = __esm({
4460
4503
  // `requestTimeout: 0` (terukur dari `buildApp()`) — tak ada pihak lain yang akan memutus peminta.
4461
4504
  // 0 = tanpa antrean sama sekali: penuh berarti langsung ditolak.
4462
4505
  queueWaitSec: external_exports.number().int().min(0).max(900).default(120),
4506
+ // SPEC-485 · ADR-0102 · umur maksimum satu RANTAI keputusan yang dibiarkan terbuka. Peminta bisa
4507
+ // mati di tengah rantai (sesi ditutup, agen crash); tanpa batas ini alurnya `sebagian` selamanya
4508
+ // dan tak ada yang tahu apakah ia masih ditunggu. Penyapunya menumpang tick lead yang sudah ada —
4509
+ // ADR-0024 melarang timer/scheduler baru.
4510
+ flowTtlMin: external_exports.number().int().min(1).max(1440).default(60),
4463
4511
  // OQ-3 · syarat objektif sebelum lead boleh mengintegrasikan ke branch utama. Default MENYALA:
4464
4512
  // risiko "kode masuk main tanpa mata manusia" diterima sadar, tapi syaratnya tetap terukur.
4465
4513
  requireGreenBeforeIntegrate: external_exports.boolean().default(true),
@@ -4642,6 +4690,9 @@ function mentionsOf(v) {
4642
4690
  for (const x of v) if (typeof x === "string" && x && !out3.includes(x)) out3.push(x);
4643
4691
  return out3;
4644
4692
  }
4693
+ function runtimeOf(v) {
4694
+ return typeof v === "string" && AGENT_RUNTIMES.includes(v) ? v : null;
4695
+ }
4645
4696
  function toolsOf(v) {
4646
4697
  if (!Array.isArray(v)) return null;
4647
4698
  const out3 = [];
@@ -4685,11 +4736,17 @@ function effectiveAgents(globals, project) {
4685
4736
  for (const a of project) byName.set(a.name, a);
4686
4737
  return [...byName.values()].filter((a) => a.enabled).sort((x, y) => x.name.localeCompare(y.name));
4687
4738
  }
4688
- var AGENT_NAME_RE, DEFAULT_AGENT_TOOLS, MENTION_TOOL, MENTION_MAX_HOPS, GLOBAL_SCOPE, zCustomAgent, zCreateCustomAgent, zUpdateCustomAgent, customAgentId;
4739
+ var AGENT_RUNTIMES, zAgentRuntime, AGENT_RUNTIME_LABELS, AGENT_NAME_RE, DEFAULT_AGENT_TOOLS, MENTION_TOOL, MENTION_MAX_HOPS, GLOBAL_SCOPE, zCustomAgent, zCreateCustomAgent, zUpdateCustomAgent, customAgentId;
4689
4740
  var init_custom_agent = __esm({
4690
4741
  "../shared/src/custom-agent.ts"() {
4691
4742
  "use strict";
4692
4743
  init_zod();
4744
+ AGENT_RUNTIMES = ["claude", "codex"];
4745
+ zAgentRuntime = external_exports.enum(AGENT_RUNTIMES);
4746
+ AGENT_RUNTIME_LABELS = {
4747
+ claude: "Claude Code",
4748
+ codex: "Codex CLI"
4749
+ };
4693
4750
  AGENT_NAME_RE = /^[a-z][a-z0-9-]{1,39}$/;
4694
4751
  DEFAULT_AGENT_TOOLS = [
4695
4752
  "Read",
@@ -4713,6 +4770,7 @@ var init_custom_agent = __esm({
4713
4770
  tools: external_exports.array(external_exports.string()).nullable(),
4714
4771
  model: external_exports.string().nullable(),
4715
4772
  mentions: external_exports.array(external_exports.string()).nullable(),
4773
+ runtime: external_exports.enum(AGENT_RUNTIMES).nullable(),
4716
4774
  enabled: external_exports.boolean(),
4717
4775
  createdAt: external_exports.string(),
4718
4776
  updatedAt: external_exports.string()
@@ -4725,6 +4783,8 @@ var init_custom_agent = __esm({
4725
4783
  tools: external_exports.array(external_exports.string()).nullable().optional(),
4726
4784
  model: external_exports.string().nullable().optional(),
4727
4785
  mentions: external_exports.array(external_exports.string()).nullable().optional(),
4786
+ // SPEC-484 · ADR-0101 · PENYARING mesin sesi, bukan pemilih proses. null/absen = ikut sesi induk.
4787
+ runtime: external_exports.enum(AGENT_RUNTIMES).nullable().optional(),
4728
4788
  enabled: external_exports.boolean().optional()
4729
4789
  });
4730
4790
  zUpdateCustomAgent = zCreateCustomAgent.omit({ name: true, projectId: true }).partial();
@@ -4732,6 +4792,44 @@ var init_custom_agent = __esm({
4732
4792
  }
4733
4793
  });
4734
4794
 
4795
+ // ../shared/src/agent-catalog.ts
4796
+ function modelsForRuntime(rt) {
4797
+ const claude = MODELS.map((m) => ({ id: m.id, label: m.label, runtime: "claude" }));
4798
+ const codex2 = CODEX_MODELS.map((m) => ({ id: m.id, label: m.label, runtime: "codex" }));
4799
+ if (rt === "claude") return claude;
4800
+ if (rt === "codex") return codex2;
4801
+ return [...claude, ...codex2];
4802
+ }
4803
+ function expandTools(tools, catalogIds) {
4804
+ if (tools === null) return null;
4805
+ if (!tools.includes(ALL_TOOLS)) return tools;
4806
+ return catalogIds.filter((id) => id !== ALL_TOOLS);
4807
+ }
4808
+ var ALL_TOOLS, ALL_TOOLS_ENTRY, BUILTIN_AGENT_TOOLS, mcpToolEntry;
4809
+ var init_agent_catalog = __esm({
4810
+ "../shared/src/agent-catalog.ts"() {
4811
+ "use strict";
4812
+ init_custom_agent();
4813
+ init_entities();
4814
+ ALL_TOOLS = "*";
4815
+ ALL_TOOLS_ENTRY = {
4816
+ id: ALL_TOOLS,
4817
+ label: "Semua tools",
4818
+ group: "shortcut"
4819
+ };
4820
+ BUILTIN_AGENT_TOOLS = DEFAULT_AGENT_TOOLS.map((id) => ({
4821
+ id,
4822
+ label: id,
4823
+ group: "builtin"
4824
+ }));
4825
+ mcpToolEntry = (server) => ({
4826
+ id: `mcp__${server}__*`,
4827
+ label: `${server} \u2014 semua tool`,
4828
+ group: "mcp"
4829
+ });
4830
+ }
4831
+ });
4832
+
4735
4833
  // ../shared/src/github.ts
4736
4834
  function sourceForLabels(labels) {
4737
4835
  const hay = labels.map((l) => l.toLowerCase());
@@ -4807,6 +4905,36 @@ function resolveChoice(raw, options2) {
4807
4905
  if (byPrefix.length === 1) return pick2(byPrefix[0]);
4808
4906
  return null;
4809
4907
  }
4908
+ function normalizeSelect(sel, optionCount) {
4909
+ const n2 = Math.max(0, optionCount);
4910
+ if (sel.mode === "single") return { mode: "single", min: Math.min(sel.min, 1), max: 1 };
4911
+ const max = Math.min(sel.max ?? n2, n2);
4912
+ return { mode: "multi", min: Math.min(sel.min, max), max };
4913
+ }
4914
+ function resolveChoices(raw, options2) {
4915
+ const seen = /* @__PURE__ */ new Set();
4916
+ const choices = [];
4917
+ const rejected = [];
4918
+ for (const r of raw ?? []) {
4919
+ const t = (r ?? "").trim();
4920
+ if (!t) continue;
4921
+ const hit = resolveChoice(t, options2);
4922
+ if (!hit) {
4923
+ rejected.push(t);
4924
+ continue;
4925
+ }
4926
+ if (seen.has(hit.index)) continue;
4927
+ seen.add(hit.index);
4928
+ choices.push(hit);
4929
+ }
4930
+ choices.sort((a, b) => a.index - b.index);
4931
+ return { choices, rejected };
4932
+ }
4933
+ function checkChoiceCount(n2, b) {
4934
+ if (n2 > b.max) return `pilihan terlalu banyak (${n2}) \u2014 paling banyak ${b.max}`;
4935
+ if (n2 < b.min) return `pilihan terlalu sedikit (${n2}) \u2014 paling sedikit ${b.min}`;
4936
+ return null;
4937
+ }
4810
4938
  function clampProse(s2, max) {
4811
4939
  const t = (s2 ?? "").replace(/\s+/g, " ").trim();
4812
4940
  if (t.length <= max) return t;
@@ -4824,10 +4952,11 @@ function leadReplyText(d) {
4824
4952
  const budget = LEAD_DECISION_MAX + LEAD_REASON_MAX;
4825
4953
  if (d.missing.length)
4826
4954
  return clampProse(`Belum bisa kuputuskan. Yang kurang: ${d.missing.join("; ")}.`, budget);
4827
- if (d.choice) return clampProse(`Pilih: ${d.choice.option}. ${d.reason}`, budget);
4955
+ const picked = d.choices?.length ? d.choices : d.choice ? [d.choice] : [];
4956
+ if (picked.length) return clampProse(`Pilih: ${picked.map((c) => c.option).join("; ")}. ${d.reason}`, budget);
4828
4957
  return clampProse(d.reply || d.decision, budget);
4829
4958
  }
4830
- var zLeadGate, zLeadKind, zLeadConfidence, zLeadStatus, LEAD_ACTIONS, zLeadAction, LEAD_FORBIDDEN, zLeadChoice, LEAD_DECISION_MAX, LEAD_REASON_MAX, norm, headOf, zLeadVerdict, zLeadAsk, zLeadOverride;
4959
+ var zLeadGate, zLeadKind, zLeadConfidence, zLeadStatus, zLeadFlowStatus, LEAD_FLOW_OPEN, LEAD_ACTIONS, zLeadAction, LEAD_FORBIDDEN, zLeadChoice, LEAD_DECISION_MAX, LEAD_REASON_MAX, norm, headOf, zLeadSelect, zLeadVerdict, zLeadAsk, zLeadOverride;
4831
4960
  var init_lead = __esm({
4832
4961
  "../shared/src/lead.ts"() {
4833
4962
  "use strict";
@@ -4836,6 +4965,8 @@ var init_lead = __esm({
4836
4965
  zLeadKind = external_exports.enum(["answer", "order", "collision", "quality", "refusal"]);
4837
4966
  zLeadConfidence = external_exports.enum(["tinggi", "sedang", "ragu"]);
4838
4967
  zLeadStatus = external_exports.enum(["berlaku", "ditimpa", "dibatalkan", "gagal"]);
4968
+ zLeadFlowStatus = external_exports.enum(["menunggu", "sebagian", "selesai", "dibatalkan"]);
4969
+ LEAD_FLOW_OPEN = ["menunggu", "sebagian"];
4839
4970
  LEAD_ACTIONS = [
4840
4971
  "none",
4841
4972
  // keputusan murni jawaban; tak ada tindakan menyusul
@@ -4878,6 +5009,11 @@ var init_lead = __esm({
4878
5009
  LEAD_REASON_MAX = 480;
4879
5010
  norm = (s2) => s2.replace(/\s+/g, " ").trim().toLowerCase();
4880
5011
  headOf = (s2) => norm(s2.split(/\s+[—–-]\s+|:/)[0] ?? s2);
5012
+ zLeadSelect = external_exports.object({
5013
+ mode: external_exports.enum(["single", "multi"]).default("single"),
5014
+ min: external_exports.number().int().min(0).max(20).default(0),
5015
+ max: external_exports.number().int().min(1).max(20).nullable().default(null)
5016
+ });
4881
5017
  zLeadVerdict = external_exports.object({
4882
5018
  decision: external_exports.string().min(1),
4883
5019
  reason: external_exports.string().min(1),
@@ -4894,6 +5030,14 @@ var init_lead = __esm({
4894
5030
  * yang sama persis dengan `action` di atas.
4895
5031
  */
4896
5032
  choice: external_exports.string().default(""),
5033
+ /**
5034
+ * SPEC-485 · pilihan JAMAK. `string[]` dengan alasan yang sama persis seperti `choice` & `action`
5035
+ * di atas: pilihan di luar daftar harus BISA MASUK supaya server menolaknya secara sadar dan
5036
+ * mencatatnya. Kosong + `choice` terisi dibaca sebagai satu pilihan — keluaran agen berbentuk
5037
+ * ADR-0098 harus tetap terpakai, dan menuntut field baru berarti setiap agen lama mendadak
5038
+ * "tak memilih apa pun".
5039
+ */
5040
+ choices: external_exports.array(external_exports.string().max(2e3)).max(20).default([]),
4897
5041
  /**
4898
5042
  * SPEC-480 · apa yang KURANG bila konteksnya memang tak cukup untuk memutuskan. Bukan pengganti
4899
5043
  * `confidence: "ragu"` (bukti tipis, jawabannya tetap ada) melainkan untuk fakta konkret yang
@@ -4909,11 +5053,23 @@ var init_lead = __esm({
4909
5053
  sessionId: external_exports.string().nullish(),
4910
5054
  question: external_exports.string().min(1).max(8e3),
4911
5055
  options: external_exports.array(external_exports.string().max(2e3)).max(20).default([]),
4912
- context: external_exports.string().max(2e4).default("")
5056
+ context: external_exports.string().max(2e4).default(""),
5057
+ /** SPEC-485 · bentuk pilihan yang diminta peminta. Default = perilaku hari ini (single). */
5058
+ select: zLeadSelect.default({ mode: "single", min: 0, max: null }),
5059
+ /** `true` = peminta akan mengajukan pertanyaan lanjutan; alurnya dibiarkan terbuka sampai submit. */
5060
+ chain: external_exports.boolean().default(false),
5061
+ /** Lanjutkan rantai yang sudah ada. Alur tertutup ditolak 409 — bukan dibuatkan diam-diam. */
5062
+ flowId: external_exports.string().min(1).nullish().default(null)
4913
5063
  });
4914
5064
  zLeadOverride = external_exports.object({
4915
5065
  answer: external_exports.string().min(1).max(8e3),
4916
- reason: external_exports.string().max(8e3).default("")
5066
+ reason: external_exports.string().max(8e3).default(""),
5067
+ /**
5068
+ * SPEC-485 · centang operator sebagai DATA, bukan prosa. Dipetakan ke opsi baris yang ditimpa,
5069
+ * lalu ikut diketikkan ke pane — jadi manusia mencentang di dashboard dan kotaknya benar-benar
5070
+ * tercentang di dialog sesi.
5071
+ */
5072
+ choices: external_exports.array(external_exports.string().max(2e3)).max(20).default([])
4917
5073
  });
4918
5074
  }
4919
5075
  });
@@ -4922,13 +5078,14 @@ var init_lead = __esm({
4922
5078
  function flowForSource(source) {
4923
5079
  return source === "qa" ? "qa" : source === "audit" ? "audit" : source === "goal" ? "goal" : "feature";
4924
5080
  }
4925
- var zIssueDeviceToken, zSessionResult, zSessionHistory, zCreateProject, zProjectId, zRenameProject, 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, zTicketView, zTicketAttachmentView, zTicketDetail, zTicketEditInput, zHelpInfo, zPublicTicketStatus;
5081
+ var zIssueDeviceToken, zSessionResult, zSessionHistory, zCreateProject, zProjectId, zRenameProject, zUpdateProject, zCreateSpec, zPatchSpec, zIntegrate, zSessionSummary, zProjectView, zSchedulerQueueItem, zSchedulerSourceView, zSchedulerSessionView, zSchedulerState, zLeadDecisionView, zLeadFlowView, 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, zTicketView, zTicketAttachmentView, zTicketDetail, zTicketEditInput, zHelpInfo, zPublicTicketStatus;
4926
5082
  var init_dto = __esm({
4927
5083
  "../shared/src/dto.ts"() {
4928
5084
  "use strict";
4929
5085
  init_zod();
4930
5086
  init_entities();
4931
5087
  init_lead();
5088
+ init_auto_merge();
4932
5089
  init_enums();
4933
5090
  zIssueDeviceToken = external_exports.object({ name: external_exports.string().min(1) });
4934
5091
  zSessionResult = external_exports.object({
@@ -4981,8 +5138,11 @@ var init_dto = __esm({
4981
5138
  // SPEC-217 · path default/server editable (null = kosongkan)
4982
5139
  schedulerOptIn: external_exports.boolean().optional(),
4983
5140
  // SPEC-294 · opt-in scheduler otonom (lokal, tak disync)
4984
- leadOptIn: external_exports.boolean().optional()
5141
+ leadOptIn: external_exports.boolean().optional(),
4985
5142
  // SPEC-409 · ADR-0091 · opt-in hanoman-lead (lokal, tak disync)
5143
+ // SPEC-486 · ADR-0103 · kebijakan auto-merge project (null = kosongkan → tanpa auto-merge).
5144
+ // Divalidasi server terhadap repo (checkAutoMerge): repoDir wajib ada, branch wajib nyata.
5145
+ autoMerge: zAutoMerge.nullable().optional()
4986
5146
  });
4987
5147
  zCreateSpec = external_exports.object({
4988
5148
  project: external_exports.string(),
@@ -5011,7 +5171,11 @@ var init_dto = __esm({
5011
5171
  // SPEC-447 · ADR-0093 · SENGAJA di luar gerbang `editingContent` (SPEC-186): gerbang itu
5012
5172
  // melindungi konten yang sudah jadi dasar kerja sesi berjalan, sedangkan dependsOn hanya
5013
5173
  // menggerbangi peluncuran BERIKUTNYA. `[]` = kosongkan.
5014
- dependsOn: external_exports.array(external_exports.string()).optional()
5174
+ dependsOn: external_exports.array(external_exports.string()).optional(),
5175
+ // SPEC-486 · ADR-0103 · override per item; `null` mengembalikannya ke warisan project.
5176
+ // SENGAJA di luar gerbang `editingContent` (SPEC-186), sama seperti dependsOn: ia menggerbangi
5177
+ // apa yang terjadi SESUDAH kerja, bukan konten yang sedang dikerjakan sesi hidup.
5178
+ autoMerge: zAutoMerge.nullable().optional()
5015
5179
  });
5016
5180
  zIntegrate = external_exports.object({
5017
5181
  op: external_exports.enum(["merge", "rebase"]),
@@ -5034,7 +5198,9 @@ var init_dto = __esm({
5034
5198
  // SPEC-253 · Help Center publik aktif
5035
5199
  schedulerOptIn: external_exports.boolean().default(false),
5036
5200
  // SPEC-294 · opt-in scheduler otonom
5037
- leadOptIn: external_exports.boolean().default(false)
5201
+ leadOptIn: external_exports.boolean().default(false),
5202
+ // SPEC-409 · ADR-0091 · opt-in hanoman-lead
5203
+ autoMerge: zAutoMerge.nullable().default(null)
5038
5204
  });
5039
5205
  zSchedulerQueueItem = external_exports.object({
5040
5206
  id: external_exports.string(),
@@ -5091,11 +5257,32 @@ var init_dto = __esm({
5091
5257
  choiceIndex: external_exports.number().nullable().default(null),
5092
5258
  options: external_exports.array(external_exports.string()).default([]),
5093
5259
  missing: external_exports.array(external_exports.string()).default([]),
5260
+ // SPEC-485 · ADR-0102 · jawaban SELALU daftar di permukaan baca. Baris pra-migrasi tak punya
5261
+ // kolomnya, jadi `toDecisionView` MENURUNKANNYA dari `choice`/`choiceIndex` — itulah yang
5262
+ // membuat riwayat lama tetap terbaca sesudah perubahan skema.
5263
+ choices: external_exports.array(zLeadChoice).default([]),
5264
+ select: zLeadSelect.nullable().default(null),
5265
+ flowId: external_exports.string().nullable().default(null),
5266
+ step: external_exports.number().nullable().default(null),
5094
5267
  status: zLeadStatus,
5095
5268
  weighty: external_exports.boolean(),
5096
5269
  supersededById: external_exports.string().nullable(),
5097
5270
  createdAt: external_exports.string()
5098
5271
  });
5272
+ zLeadFlowView = external_exports.object({
5273
+ id: external_exports.string(),
5274
+ projectId: external_exports.string(),
5275
+ specId: external_exports.string().nullable(),
5276
+ sessionId: external_exports.string().nullable(),
5277
+ gate: zLeadGate,
5278
+ status: zLeadFlowStatus,
5279
+ title: external_exports.string(),
5280
+ steps: external_exports.number(),
5281
+ closeReason: external_exports.string().nullable(),
5282
+ openedAt: external_exports.string(),
5283
+ closedAt: external_exports.string().nullable(),
5284
+ expiresAt: external_exports.string()
5285
+ });
5099
5286
  zLeadAnswer = external_exports.object({
5100
5287
  id: external_exports.string(),
5101
5288
  decision: external_exports.string(),
@@ -5107,7 +5294,12 @@ var init_dto = __esm({
5107
5294
  // dipilih sebagai field, bukan sesuatu yang harus ditebak dari `decision`. `decision`/`reason`
5108
5295
  // di sini TERPANGKAS (jejak penuh ada di `GET /lead/decisions`).
5109
5296
  choice: zLeadChoice.nullable().default(null),
5110
- missing: external_exports.array(external_exports.string()).default([])
5297
+ missing: external_exports.array(external_exports.string()).default([]),
5298
+ // SPEC-485 · ADR-0102 · `choices` adalah bentuk yang berlaku; `choice` di atas tinggal
5299
+ // `choices[0]`. `flowId`/`flowStatus` memberi peminta pegangan untuk melanjutkan rantainya.
5300
+ choices: external_exports.array(zLeadChoice).default([]),
5301
+ flowId: external_exports.string().nullable().default(null),
5302
+ flowStatus: zLeadFlowStatus.nullable().default(null)
5111
5303
  });
5112
5304
  zLeadProjectStatus = external_exports.object({
5113
5305
  projectId: external_exports.string(),
@@ -5361,6 +5553,9 @@ var init_api = __esm({
5361
5553
  limits: `${API}/limits`,
5362
5554
  // SPEC-339 · versi codex CLI (peringatan lunak model GPT-5.6).
5363
5555
  codexVersion: `${API}/codex/version`,
5556
+ // SPEC-489 · panduan AI agent — markdown MENTAH & PUBLIC (tanpa auth). Satu definisi URL untuk
5557
+ // klien web dan untuk tautan yang disalin operator ke agennya.
5558
+ agentDoc: `${API}/agent-integration.md`,
5364
5559
  docs: (id) => `${API}/projects/${id}/docs`,
5365
5560
  docFile: (id, path) => `${API}/projects/${id}/docs/${path}`,
5366
5561
  // SPEC-210 · dokumen PRD project (freshest-wins: worktree sesi prd hidup > repoDir)
@@ -5467,8 +5662,15 @@ var init_api = __esm({
5467
5662
  leadDecisions: `${API}/lead/decisions`,
5468
5663
  leadDecisionOverride: (id) => `${API}/lead/decisions/${encodeURIComponent(id)}/override`,
5469
5664
  leadDecisionCancel: (id) => `${API}/lead/decisions/${encodeURIComponent(id)}/cancel`,
5665
+ // SPEC-485 · ADR-0102 · rantai keputusan. Di bawah prefix `lead` supaya capability-nya turunan
5666
+ // method (lead:read/lead:write) tanpa peta baru — kelas bug SPEC-405 sudah ditutup di sana.
5667
+ leadFlows: `${API}/lead/flows`,
5668
+ leadFlowSubmit: (id) => `${API}/lead/flows/${encodeURIComponent(id)}/submit`,
5669
+ leadFlowCancel: (id) => `${API}/lead/flows/${encodeURIComponent(id)}/cancel`,
5470
5670
  // SPEC-450 · ADR-0094 · katalog custom agent. `?projectId=` → himpunan EFEKTIF (global+project).
5471
5671
  customAgents: `${API}/custom-agents`,
5672
+ // SPEC-484 · ADR-0101 · sumber daftar tools/model/runtime untuk form (mention dari `customAgents`).
5673
+ customAgentCatalog: `${API}/custom-agents/catalog`,
5472
5674
  // SPEC-481 · ADR-0100 · webhook keluar (cookie-only)
5473
5675
  webhooks: `${API}/webhooks`,
5474
5676
  webhook: (id) => `${API}/webhooks/${encodeURIComponent(id)}`,
@@ -5680,6 +5882,7 @@ var init_config_registry = __esm({
5680
5882
  kind: "url",
5681
5883
  apply: "live",
5682
5884
  category: "knob",
5885
+ example: "https://hanoman.nafanesia.id",
5683
5886
  help: "Base URL hub tujuan sync (REST + WS). Kosong = instance ini murni HUB."
5684
5887
  },
5685
5888
  {
@@ -5689,6 +5892,7 @@ var init_config_registry = __esm({
5689
5892
  kind: "secret",
5690
5893
  apply: "live",
5691
5894
  category: "credential",
5895
+ example: "43 karakter base64url dari tab Perangkat hub",
5692
5896
  help: "Token yang diterbitkan hub (tab Perangkat di hub). Dikirim sebagai Bearer."
5693
5897
  },
5694
5898
  {
@@ -5698,6 +5902,7 @@ var init_config_registry = __esm({
5698
5902
  kind: "int",
5699
5903
  apply: "live",
5700
5904
  category: "knob",
5905
+ example: "15000",
5701
5906
  default: "15000",
5702
5907
  min: 1e3
5703
5908
  },
@@ -5709,13 +5914,14 @@ var init_config_registry = __esm({
5709
5914
  kind: "secret",
5710
5915
  apply: "new-session",
5711
5916
  category: "credential",
5917
+ example: "sk-ant-oat01-\u2026",
5712
5918
  inheritEnv: true,
5713
5919
  help: "Token `claude setup-token`. Diwarisi proses claude yang di-spawn."
5714
5920
  },
5715
- { key: "ANTHROPIC_API_KEY", group: "claude", label: "Anthropic API key", kind: "secret", apply: "new-session", category: "credential", inheritEnv: true },
5716
- { key: "HANOMAN_CLAUDE_BIN", group: "claude", label: "Biner claude", kind: "path", apply: "new-session", category: "knob", default: "claude" },
5921
+ { key: "ANTHROPIC_API_KEY", group: "claude", label: "Anthropic API key", kind: "secret", apply: "new-session", category: "credential", example: "sk-ant-api03-\u2026", inheritEnv: true },
5922
+ { key: "HANOMAN_CLAUDE_BIN", group: "claude", label: "Biner claude", kind: "path", apply: "new-session", category: "knob", example: "/opt/homebrew/bin/claude", default: "claude" },
5717
5923
  // codex (SPEC-338 · ADR-0074)
5718
- { key: "HANOMAN_CODEX_BIN", group: "claude", label: "Biner codex", kind: "path", apply: "new-session", category: "knob", default: "codex" },
5924
+ { key: "HANOMAN_CODEX_BIN", group: "claude", label: "Biner codex", kind: "path", apply: "new-session", category: "knob", example: "/opt/homebrew/bin/codex", default: "codex" },
5719
5925
  {
5720
5926
  key: "CLAUDE_CONFIG_DIR",
5721
5927
  group: "claude",
@@ -5723,11 +5929,12 @@ var init_config_registry = __esm({
5723
5929
  kind: "path",
5724
5930
  apply: "new-session",
5725
5931
  category: "knob",
5932
+ example: "~/.claude",
5726
5933
  help: "Default ~/.claude. Sumber .credentials.json untuk panel usage/limit."
5727
5934
  },
5728
5935
  // vps
5729
- { key: "HANOMAN_SSH_KEY_DIR", group: "vps", label: "Dir key SSH", kind: "path", apply: "new-session", category: "knob", help: "Default ~/.hanoman." },
5730
- { key: "HANOMAN_SSH_BIN", group: "vps", label: "Biner ssh", kind: "path", apply: "new-session", category: "knob", default: "ssh" },
5936
+ { key: "HANOMAN_SSH_KEY_DIR", group: "vps", label: "Dir key SSH", kind: "path", apply: "new-session", category: "knob", example: "~/.hanoman", help: "Default ~/.hanoman." },
5937
+ { key: "HANOMAN_SSH_BIN", group: "vps", label: "Biner ssh", kind: "path", apply: "new-session", category: "knob", example: "/usr/bin/ssh", default: "ssh" },
5731
5938
  // github (SPEC-471 · ADR-0095 · tarik issue → backlog). Dua mode auth, satu jalur kode:
5732
5939
  // `gh` memakai keyring mesin; bila GITHUB_TOKEN diisi ia diteruskan sebagai GH_TOKEN ke
5733
5940
  // proses gh DAN dipakai jalur REST (hub VPS yang tak punya keyring).
@@ -5738,6 +5945,7 @@ var init_config_registry = __esm({
5738
5945
  kind: "secret",
5739
5946
  apply: "live",
5740
5947
  category: "credential",
5948
+ example: "ghp_\u2026",
5741
5949
  help: "PAT scope `repo` (atau `public_repo`). Kosong = andalkan `gh auth login` di mesin ini."
5742
5950
  },
5743
5951
  {
@@ -5747,6 +5955,7 @@ var init_config_registry = __esm({
5747
5955
  kind: "path",
5748
5956
  apply: "live",
5749
5957
  category: "knob",
5958
+ example: "/opt/homebrew/bin/gh",
5750
5959
  default: "gh",
5751
5960
  help: "Absen = tarik issue lewat HTTPS langsung ke api.github.com."
5752
5961
  },
@@ -5759,6 +5968,7 @@ var init_config_registry = __esm({
5759
5968
  kind: "secret",
5760
5969
  apply: "live",
5761
5970
  category: "credential",
5971
+ example: "123456789:AAE\u2026",
5762
5972
  pattern: "^\\d{5,}:[A-Za-z0-9_-]{30,}$",
5763
5973
  patternError: "format BotFather: <bot_id>:<secret>",
5764
5974
  help: "Token dari BotFather. Disimpan terenkripsi; tak pernah dikembalikan utuh."
@@ -5770,6 +5980,7 @@ var init_config_registry = __esm({
5770
5980
  kind: "secret",
5771
5981
  apply: "live",
5772
5982
  category: "credential",
5983
+ example: "hnm_agt_\u2026",
5773
5984
  pattern: "^\\S{20,}$",
5774
5985
  patternError: "plaintext AgentToken (hnm_agt_\u2026)",
5775
5986
  help: "Plaintext AgentToken ber-capability Telegram. Dipakai session operator, bukan bot."
@@ -5781,6 +5992,7 @@ var init_config_registry = __esm({
5781
5992
  kind: "string",
5782
5993
  apply: "live",
5783
5994
  category: "credential",
5995
+ example: "123456789, 987654321",
5784
5996
  pattern: "^\\d+(?:[\\s,]+\\d+)*$",
5785
5997
  patternError: "numeric user id, dipisah koma/spasi",
5786
5998
  help: "Siapa yang boleh memerintah bot. Numeric Telegram user id, bukan username."
@@ -5792,12 +6004,13 @@ var init_config_registry = __esm({
5792
6004
  kind: "string",
5793
6005
  apply: "live",
5794
6006
  category: "knob",
6007
+ example: "-1001234567890",
5795
6008
  pattern: "^-?\\d+$",
5796
6009
  patternError: "numeric chat id (channel/supergroup negatif)",
5797
6010
  help: "Tujuan Test Connection. Kosong = pakai satu-satunya id di allowlist."
5798
6011
  },
5799
6012
  // runtime
5800
- { key: "HANOMAN_EVENTS_TICK_MS", group: "runtime", label: "Interval events (ms)", kind: "int", apply: "live", category: "knob", default: "1000", min: 100 },
6013
+ { key: "HANOMAN_EVENTS_TICK_MS", group: "runtime", label: "Interval events (ms)", kind: "int", apply: "live", category: "knob", example: "1000", default: "1000", min: 100 },
5801
6014
  { key: "HANOMAN_UPDATE_FETCH", group: "runtime", label: "Deteksi update saat boot", kind: "bool", apply: "live", category: "knob", default: "1" },
5802
6015
  // SPEC-398 · ADR-0087 · deteksi update kini membandingkan semver dengan registry npm, bukan
5803
6016
  // menghitung commit di repo git (HANOMAN_REPO_ROOT dicabut bersama jalur git itu).
@@ -5808,6 +6021,7 @@ var init_config_registry = __esm({
5808
6021
  kind: "string",
5809
6022
  apply: "live",
5810
6023
  category: "knob",
6024
+ example: "https://registry.npmjs.org",
5811
6025
  default: "https://registry.npmjs.org",
5812
6026
  help: "Sumber versi terbaru paket `hanoman`."
5813
6027
  },
@@ -5818,15 +6032,16 @@ var init_config_registry = __esm({
5818
6032
  kind: "string",
5819
6033
  apply: "restart",
5820
6034
  category: "knob",
6035
+ example: "hanoman",
5821
6036
  default: "hanoman",
5822
6037
  help: "Mengubah ini TIDAK memindahkan sesi tmux yang sudah hidup \u2014 berlaku setelah restart."
5823
6038
  },
5824
6039
  // gitGraph (SPEC-233 · preferensi tampilan git graph; semua live, dikonsumsi client)
5825
- { key: "gitGraph.style", group: "gitGraph", label: "Gaya graph", kind: "string", apply: "live", category: "knob", default: "rounded", help: "rounded | angular" },
5826
- { key: "gitGraph.colours", group: "gitGraph", label: "Warna lane (CSV)", kind: "string", apply: "live", category: "knob", help: "Daftar warna hex dipisah koma; kosong = palet bawaan." },
5827
- { key: "gitGraph.dateType", group: "gitGraph", label: "Jenis tanggal", kind: "string", apply: "live", category: "knob", default: "author", help: "author | commit" },
5828
- { key: "gitGraph.commitsInitialLoad", group: "gitGraph", label: "Muat awal", kind: "int", apply: "live", category: "knob", default: "200", min: 1 },
5829
- { key: "gitGraph.commitsLoadMore", group: "gitGraph", label: "Muat lagi", kind: "int", apply: "live", category: "knob", default: "100", min: 1 },
6040
+ { key: "gitGraph.style", group: "gitGraph", label: "Gaya graph", kind: "string", apply: "live", category: "knob", example: "rounded", default: "rounded", help: "rounded | angular" },
6041
+ { key: "gitGraph.colours", group: "gitGraph", label: "Warna lane (CSV)", kind: "string", apply: "live", category: "knob", example: "#c9a227, #6b8f71, #b4614e", help: "Daftar warna hex dipisah koma; kosong = palet bawaan." },
6042
+ { key: "gitGraph.dateType", group: "gitGraph", label: "Jenis tanggal", kind: "string", apply: "live", category: "knob", example: "author", default: "author", help: "author | commit" },
6043
+ { key: "gitGraph.commitsInitialLoad", group: "gitGraph", label: "Muat awal", kind: "int", apply: "live", category: "knob", example: "200", default: "200", min: 1 },
6044
+ { key: "gitGraph.commitsLoadMore", group: "gitGraph", label: "Muat lagi", kind: "int", apply: "live", category: "knob", example: "100", default: "100", min: 1 },
5830
6045
  { key: "gitGraph.showRemoteBranches", group: "gitGraph", label: "Tampilkan remote", kind: "bool", apply: "live", category: "knob", default: "1" },
5831
6046
  { key: "gitGraph.showTags", group: "gitGraph", label: "Tampilkan tag", kind: "bool", apply: "live", category: "knob", default: "1" },
5832
6047
  { key: "gitGraph.showStashes", group: "gitGraph", label: "Tampilkan stash", kind: "bool", apply: "live", category: "knob", default: "1" },
@@ -5836,7 +6051,7 @@ var init_config_registry = __esm({
5836
6051
  { key: "gitGraph.combineLocalRemote", group: "gitGraph", label: "Gabung label local+remote", kind: "bool", apply: "live", category: "knob", default: "0" },
5837
6052
  { key: "gitGraph.markdown", group: "gitGraph", label: "Render markdown pesan", kind: "bool", apply: "live", category: "knob", default: "1" },
5838
6053
  { key: "gitGraph.emoji", group: "gitGraph", label: "Render emoji shortcode", kind: "bool", apply: "live", category: "knob", default: "1" },
5839
- { key: "gitGraph.issueLinkPattern", group: "gitGraph", label: "Pola link issue", kind: "string", apply: "live", category: "knob", help: "URL dengan $1 untuk nomor issue, mis. https://github.com/acme/app/issues/$1" },
6054
+ { key: "gitGraph.issueLinkPattern", group: "gitGraph", label: "Pola link issue", kind: "string", apply: "live", category: "knob", example: "https://github.com/acme/app/issues/$1", help: "URL dengan $1 untuk nomor issue, mis. https://github.com/acme/app/issues/$1" },
5840
6055
  // bootstrap (read-only)
5841
6056
  { key: "DATABASE_URL", group: "bootstrap", label: "DATABASE_URL", kind: "secret", apply: "restart", category: "bootstrap" },
5842
6057
  { key: "TEST_DATABASE_URL", group: "bootstrap", label: "TEST_DATABASE_URL", kind: "secret", apply: "restart", category: "bootstrap" },
@@ -5985,6 +6200,7 @@ var init_webhook = __esm({
5985
6200
  "baseSha",
5986
6201
  "headSha",
5987
6202
  "dependsOn",
6203
+ "autoMerge",
5988
6204
  "createdAt",
5989
6205
  "startedAt",
5990
6206
  "updatedAt"
@@ -6014,6 +6230,7 @@ var init_webhook = __esm({
6014
6230
  baseSha: "5117298c5a3e63af76cbadaa46e2edfa50921d7",
6015
6231
  headSha: null,
6016
6232
  dependsOn: null,
6233
+ autoMerge: null,
6017
6234
  createdAt: "2026-08-01T02:10:00.000Z",
6018
6235
  startedAt: "2026-08-01T02:12:31.000Z",
6019
6236
  updatedAt: "2026-08-01T09:41:22.000Z"
@@ -6033,6 +6250,7 @@ var init_webhook = __esm({
6033
6250
  "helpEnabled",
6034
6251
  "schedulerOptIn",
6035
6252
  "leadOptIn",
6253
+ "autoMerge",
6036
6254
  "createdAt",
6037
6255
  "updatedAt"
6038
6256
  ],
@@ -6053,6 +6271,7 @@ var init_webhook = __esm({
6053
6271
  helpEnabled: false,
6054
6272
  schedulerOptIn: true,
6055
6273
  leadOptIn: false,
6274
+ autoMerge: null,
6056
6275
  createdAt: "2026-05-02T04:00:00.000Z",
6057
6276
  updatedAt: "2026-08-01T09:00:00.000Z"
6058
6277
  }
@@ -6819,7 +7038,7 @@ var init_mcp_catalog = __esm({
6819
7038
  {
6820
7039
  name: "hanoman_lead_ask",
6821
7040
  title: "Minta putusan hanoman-lead",
6822
- description: "Minta putusan ke hanoman-lead saat menemui persimpangan yang biasanya butuh manusia. Jawabannya terbaca mesin (`decision`, `reason`, `refs`, `confidence`, `action`) dan `refs` hanya memuat rujukan yang benar-benar ada di repo. Panggilan ini melahirkan jejak permanen dan putusannya bisa menggerakkan sesi \u2014 pakai hanya saat memang buntu. 409 = lead tak aktif atau proyek belum opt-in: kembali ke perilaku biasa, berhenti dan tunggu manusia.",
7041
+ description: "Minta putusan ke hanoman-lead saat menemui persimpangan yang biasanya butuh manusia. Jawabannya terbaca mesin (`decision`, `reason`, `refs`, `confidence`, `action`, `choices`) dan `refs` hanya memuat rujukan yang benar-benar ada di repo. Bila opsinya TIDAK saling eksklusif, set `multi: true` \u2014 balasannya memuat `choices` (daftar), bukan hanya `choice`. Panggilan ini melahirkan jejak permanen dan putusannya bisa menggerakkan sesi \u2014 pakai hanya saat memang buntu. 409 = lead tak aktif atau proyek belum opt-in: kembali ke perilaku biasa, berhenti dan tunggu manusia; 400 = bentuk `multi`/`minChoices`/`maxChoices` mustahil dipenuhi oleh daftar opsi yang kamu kirim.",
6823
7042
  inputSchema: obj({
6824
7043
  properties: {
6825
7044
  project: str("Id proyek."),
@@ -6827,7 +7046,10 @@ var init_mcp_catalog = __esm({
6827
7046
  spec: str("Opsional. Id backlog yang bersangkutan."),
6828
7047
  session: str("Opsional. Id sesi yang bersangkutan."),
6829
7048
  options: strArray("Opsional. Pilihan yang tersedia, maksimum 20, masing-masing maksimum 2000 karakter. Lead memilih salah satunya."),
6830
- context: str("Opsional. Konteks pendukung, maksimum 20.000 karakter.")
7049
+ context: str("Opsional. Konteks pendukung, maksimum 20.000 karakter."),
7050
+ multi: bool("Opsional. `true` bila opsinya TIDAK saling eksklusif dan lead boleh memilih beberapa sekaligus. Menuntut `options` terisi."),
7051
+ minChoices: int("Opsional, hanya untuk `multi`. Paling sedikit berapa opsi harus dipilih."),
7052
+ maxChoices: int("Opsional, hanya untuk `multi`. Paling banyak berapa opsi boleh dipilih; tanpa ini sebanyak opsinya.")
6831
7053
  },
6832
7054
  required: ["project", "question"]
6833
7055
  }),
@@ -6844,7 +7066,10 @@ var init_mcp_catalog = __esm({
6844
7066
  ...s(a.spec) ? { specId: a.spec } : {},
6845
7067
  ...s(a.session) ? { sessionId: a.session } : {},
6846
7068
  ...Array.isArray(a.options) ? { options: a.options } : {},
6847
- ...s(a.context) ? { context: a.context } : {}
7069
+ ...s(a.context) ? { context: a.context } : {},
7070
+ // SPEC-485 · ADITIF: tanpa `multi` bentuk permintaannya identik dengan sebelum ADR-0102,
7071
+ // jadi klien MCP lama tak berubah perilakunya satu bit pun.
7072
+ ...a.multi === true ? { select: { mode: "multi", min: n(a.minChoices) ?? 0, max: n(a.maxChoices) ?? null } } : {}
6848
7073
  }
6849
7074
  }),
6850
7075
  shape: (raw) => raw
@@ -6883,6 +7108,7 @@ var init_src = __esm({
6883
7108
  init_entities();
6884
7109
  init_agent();
6885
7110
  init_custom_agent();
7111
+ init_agent_catalog();
6886
7112
  init_github();
6887
7113
  init_lead();
6888
7114
  init_dto();
@@ -6896,12 +7122,13 @@ var init_src = __esm({
6896
7122
  init_telegram();
6897
7123
  init_webhook();
6898
7124
  init_mcp();
7125
+ init_auto_merge();
6899
7126
  }
6900
7127
  });
6901
7128
 
6902
7129
  // src/env.ts
6903
7130
  import { readFileSync, existsSync } from "node:fs";
6904
- import { dirname, resolve as resolve2 } from "node:path";
7131
+ import { dirname, resolve as resolve3 } from "node:path";
6905
7132
  import { fileURLToPath } from "node:url";
6906
7133
  var dir;
6907
7134
  var init_env = __esm({
@@ -6909,7 +7136,7 @@ var init_env = __esm({
6909
7136
  "use strict";
6910
7137
  dir = dirname(fileURLToPath(import.meta.url));
6911
7138
  for (let i = 0; i < 6; i++) {
6912
- const file = resolve2(dir, ".env");
7139
+ const file = resolve3(dir, ".env");
6913
7140
  if (existsSync(file)) {
6914
7141
  for (const line of readFileSync(file, "utf8").split("\n")) {
6915
7142
  const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/);
@@ -7363,7 +7590,7 @@ ${lines2.join("\n")}` : "";
7363
7590
  // ../runner/src/git.ts
7364
7591
  import { spawnSync } from "node:child_process";
7365
7592
  import { rmSync, mkdirSync, realpathSync } from "node:fs";
7366
- import { isAbsolute, resolve as resolve3 } from "node:path";
7593
+ import { isAbsolute, resolve as resolve4 } from "node:path";
7367
7594
  function git(cwd, args) {
7368
7595
  const r = spawnSync("git", args, { cwd, encoding: "utf8" });
7369
7596
  if (r.status !== 0) throw new Error(`git ${args.join(" ")} failed: ${r.stderr || r.stdout || r.error?.message || "gagal spawn"}`);
@@ -7398,7 +7625,7 @@ var init_git = __esm({
7398
7625
  addWorktree: (repo, path, branchFrom) => {
7399
7626
  tryGit(repo, ["worktree", "remove", "--force", path]);
7400
7627
  tryGit(repo, ["worktree", "prune"]);
7401
- rmSync(isAbsolute(path) ? path : resolve3(repo, path), { recursive: true, force: true });
7628
+ rmSync(isAbsolute(path) ? path : resolve4(repo, path), { recursive: true, force: true });
7402
7629
  const base2 = resolveCommit(repo, branchFrom);
7403
7630
  git(repo, ["worktree", "add", "--detach", path, base2]);
7404
7631
  return base2;
@@ -7408,8 +7635,8 @@ var init_git = __esm({
7408
7635
  // `fatal: not a working tree` → membuat DELETE /terminal/sessions balas 500. remove+prune+rm
7409
7636
  // semuanya toleran, jadi penutupan sesi selalu 204 dan registrasi worktree tak stale (SPEC-197).
7410
7637
  removeWorktree: (repo, path) => {
7411
- const target2 = isAbsolute(path) ? resolve3(path) : resolve3(repo, path);
7412
- if (target2 === resolve3(repo)) {
7638
+ const target2 = isAbsolute(path) ? resolve4(path) : resolve4(repo, path);
7639
+ if (target2 === resolve4(repo)) {
7413
7640
  throw new Error(`removeWorktree menolak menghapus repo itu sendiri: ${target2}`);
7414
7641
  }
7415
7642
  tryGit(repo, ["worktree", "remove", "--force", target2]);
@@ -7725,10 +7952,10 @@ var init_custom_agents = __esm({
7725
7952
 
7726
7953
  // ../runner/src/paths.ts
7727
7954
  import { homedir } from "node:os";
7728
- import { dirname as dirname2, isAbsolute as isAbsolute2, join, resolve as resolve4 } from "node:path";
7729
- function resolveHome(env = process.env, home = homedir()) {
7955
+ import { dirname as dirname2, isAbsolute as isAbsolute2, join, resolve as resolve5 } from "node:path";
7956
+ function resolveHome(env = process.env, home2 = homedir()) {
7730
7957
  const v = env.HANOMAN_HOME?.trim();
7731
- return v ? v : join(home, ".hanoman");
7958
+ return v ? v : join(home2, ".hanoman");
7732
7959
  }
7733
7960
  function schemeOf(raw) {
7734
7961
  return raw.split("://")[0] ?? "?";
@@ -7750,7 +7977,7 @@ function resolveDbUrl(env, schemaDir2) {
7750
7977
  function absoluteFileUrl(raw, schemaDir2) {
7751
7978
  const p = raw.slice("file:".length);
7752
7979
  if (p.startsWith(":")) return raw;
7753
- return `file:${isAbsolute2(p) ? p : resolve4(schemaDir2, p)}`;
7980
+ return `file:${isAbsolute2(p) ? p : resolve5(schemaDir2, p)}`;
7754
7981
  }
7755
7982
  function dbUrlNotice(env) {
7756
7983
  if (env.HANOMAN_DATABASE_URL?.trim()) return null;
@@ -7986,7 +8213,7 @@ var init_tap = __esm({
7986
8213
 
7987
8214
  // src/db.ts
7988
8215
  import { mkdirSync as mkdirSync2 } from "node:fs";
7989
- import { dirname as dirname3, resolve as resolve5 } from "node:path";
8216
+ import { dirname as dirname3, resolve as resolve6 } from "node:path";
7990
8217
  import { fileURLToPath as fileURLToPath2 } from "node:url";
7991
8218
  import { PrismaClient } from "@prisma/client";
7992
8219
  var schemaDir, notice, url, base, prisma;
@@ -7996,7 +8223,7 @@ var init_db = __esm({
7996
8223
  init_env();
7997
8224
  init_src2();
7998
8225
  init_tap();
7999
- schemaDir = resolve5(dirname3(fileURLToPath2(import.meta.url)), "../prisma");
8226
+ schemaDir = resolve6(dirname3(fileURLToPath2(import.meta.url)), "../prisma");
8000
8227
  notice = dbUrlNotice(process.env);
8001
8228
  if (notice) console.warn(notice);
8002
8229
  url = resolveDbUrl(process.env, schemaDir);
@@ -8145,18 +8372,38 @@ function readChoiceDialog(paneText2) {
8145
8372
  expected -= 1;
8146
8373
  }
8147
8374
  if (run2.length < 2 || run2[0].n !== 1) return null;
8148
- const rows = run2.map((r) => ({
8149
- n: r.n,
8150
- label: r.label,
8151
- free: PLACEHOLDER.test(r.label),
8152
- chat: CHAT_ROW.test(r.label)
8153
- }));
8375
+ const rows = run2.map((r) => {
8376
+ const m = CHECK.exec(r.label);
8377
+ const label = m ? (m[2] ?? "").trim() : r.label;
8378
+ return {
8379
+ n: r.n,
8380
+ label,
8381
+ checked: m ? m[1] !== " " : null,
8382
+ free: PLACEHOLDER.test(label),
8383
+ chat: CHAT_ROW.test(label)
8384
+ };
8385
+ });
8386
+ let submit = { present: false, focused: false };
8387
+ for (const line of lines2.slice(0, footer)) {
8388
+ const m = SUBMIT_BTN.exec(line);
8389
+ if (m) submit = { present: true, focused: !!m[1] };
8390
+ }
8154
8391
  return {
8155
8392
  rows,
8156
8393
  freeIndex: rows.find((r) => r.free)?.n ?? null,
8157
- options: rows.filter((r) => !r.free && !r.chat).map((r) => r.label)
8394
+ options: rows.filter((r) => !r.free && !r.chat).map((r) => r.label),
8395
+ multi: rows.some((r) => r.checked !== null),
8396
+ submit
8158
8397
  };
8159
8398
  }
8399
+ function focusedRow(paneText2) {
8400
+ const lines2 = paneText2.split("\n").map((l) => l.trimEnd());
8401
+ for (let i = lines2.length - 1; i >= 0; i--) {
8402
+ const m = /^\s*[❯>›]\s*(\d{1,2})\.\s+\S/.exec(lines2[i] ?? "");
8403
+ if (m) return Number(m[1]);
8404
+ }
8405
+ return null;
8406
+ }
8160
8407
  function readTabs(lines2) {
8161
8408
  for (let i = lines2.length - 1; i >= 0; i--) {
8162
8409
  const line = lines2[i] ?? "";
@@ -8206,15 +8453,17 @@ function readDialogScreen(paneText2) {
8206
8453
  options: d.options,
8207
8454
  notes: d.freeIndex === null && NOTES_FOOTER.test(lines2[footer] ?? ""),
8208
8455
  tabs,
8209
- title: readTitle(lines2, at, footer)
8456
+ title: readTitle(lines2, at, footer),
8457
+ multi: d.multi,
8458
+ submit: d.submit
8210
8459
  };
8211
8460
  }
8212
8461
  function dialogKey(paneText2) {
8213
8462
  const s2 = readDialogScreen(paneText2);
8214
8463
  if (!s2) return "none";
8215
8464
  if (s2.kind === "review") return "review";
8216
- const tabs = s2.tabs.map((t) => `${t.answered ? "x" : "o"}${t.header}`).join(",");
8217
- return `q|${tabs}|${s2.title || s2.options.join("|")}`;
8465
+ const tabs = s2.multi ? s2.tabs.map((t) => t.header).join(",") : s2.tabs.map((t) => `${t.answered ? "x" : "o"}${t.header}`).join(",");
8466
+ return `q|${s2.multi ? "multi|" : ""}${tabs}|${s2.title || s2.options.join("|")}`;
8218
8467
  }
8219
8468
  function freeTextFilled(paneText2, n2) {
8220
8469
  const row = readChoiceDialog(paneText2)?.rows.find((r) => r.n === n2);
@@ -8233,6 +8482,42 @@ async function answerChoiceDialog(io, freeIndex, line, chunkMs) {
8233
8482
  io.enter();
8234
8483
  return true;
8235
8484
  }
8485
+ async function answerMultiSelectDialog(io, plan, chunkMs) {
8486
+ const want = new Set(plan.pick);
8487
+ const state = () => readChoiceDialog(io.capture());
8488
+ for (const row of state()?.rows ?? []) {
8489
+ if (row.checked === null || row.free || row.chat) continue;
8490
+ if (row.checked === want.has(row.n)) continue;
8491
+ io.literal(String(row.n));
8492
+ await io.sleep(DIALOG_SETTLE_MS);
8493
+ const after = state()?.rows.find((r) => r.n === row.n);
8494
+ if (!after || after.checked !== want.has(row.n)) return false;
8495
+ }
8496
+ if (plan.line && plan.freeIndex !== null) {
8497
+ let hop2 = 0;
8498
+ while (focusedRow(io.capture()) !== plan.freeIndex && hop2 < NAV_TRIES) {
8499
+ io.down();
8500
+ await io.sleep(DIALOG_SETTLE_MS);
8501
+ hop2++;
8502
+ }
8503
+ if (focusedRow(io.capture()) !== plan.freeIndex) return false;
8504
+ for (const chunk of goalChunks(plan.line)) {
8505
+ io.literal(chunk);
8506
+ await io.sleep(chunkMs);
8507
+ }
8508
+ await io.sleep(DIALOG_SETTLE_MS);
8509
+ if (!freeTextFilled(io.capture(), plan.freeIndex)) return false;
8510
+ }
8511
+ let hop = 0;
8512
+ while (!state()?.submit.focused && hop < NAV_TRIES) {
8513
+ io.down();
8514
+ await io.sleep(DIALOG_SETTLE_MS);
8515
+ hop++;
8516
+ }
8517
+ if (!state()?.submit.focused) return false;
8518
+ io.enter();
8519
+ return true;
8520
+ }
8236
8521
  function notesFilled(paneText2) {
8237
8522
  const lines2 = paneText2.split("\n").map((l) => l.trimEnd());
8238
8523
  for (let i = lines2.length - 1; i >= 0; i--) {
@@ -8263,7 +8548,7 @@ async function submitReview(io, submitRow) {
8263
8548
  }
8264
8549
  return false;
8265
8550
  }
8266
- var FOOTER, ROW, PLACEHOLDER, CHAT_ROW, SIDE_PANEL, cleanLabel, REVIEW_PROMPT, SUBMIT_ROW, NOTES_FOOTER, TAB_BOX, lastIndexOf, RULE, DIALOG_SETTLE_MS, NOTES_PLACEHOLDER, NOTES_LINE, SUBMIT_TRIES;
8551
+ var FOOTER, ROW, PLACEHOLDER, CHAT_ROW, SIDE_PANEL, cleanLabel, CHECK, SUBMIT_BTN, REVIEW_PROMPT, SUBMIT_ROW, NOTES_FOOTER, TAB_BOX, lastIndexOf, RULE, DIALOG_SETTLE_MS, NAV_TRIES, NOTES_PLACEHOLDER, NOTES_LINE, SUBMIT_TRIES;
8267
8552
  var init_tui_dialog = __esm({
8268
8553
  "src/services/tui-dialog.ts"() {
8269
8554
  "use strict";
@@ -8274,6 +8559,8 @@ var init_tui_dialog = __esm({
8274
8559
  CHAT_ROW = /^chat about this$/i;
8275
8560
  SIDE_PANEL = /\s{2,}[│┃┌┐└┘├┤╭╮╰╯─━].*$/;
8276
8561
  cleanLabel = (s2) => s2.replace(SIDE_PANEL, "").trim();
8562
+ CHECK = /^\[([ xX✔✓])\]\s*(.*)$/;
8563
+ SUBMIT_BTN = /^\s*([❯>›])?\s{2,}(Submit|Next)\s*$/;
8277
8564
  REVIEW_PROMPT = /^\s*ready to submit your answers\?\s*$/i;
8278
8565
  SUBMIT_ROW = /^\s*[❯>›]?\s*(\d{1,2})\.\s+submit answers\s*$/i;
8279
8566
  NOTES_FOOTER = /\bn to add notes\b/i;
@@ -8284,6 +8571,7 @@ var init_tui_dialog = __esm({
8284
8571
  };
8285
8572
  RULE = /^[\s─━╌╍_=]*$/;
8286
8573
  DIALOG_SETTLE_MS = 250;
8574
+ NAV_TRIES = 24;
8287
8575
  NOTES_PLACEHOLDER = /^(?:press n to add notes|add notes on this design(?:…|\.\.\.)?)$/i;
8288
8576
  NOTES_LINE = /(?:^|\s)Notes:\s*(.*)$/;
8289
8577
  SUBMIT_TRIES = 8;
@@ -8520,7 +8808,7 @@ function createSession(projectId, cwd, opts = {}) {
8520
8808
  if (existing && !existing.exited) return existing;
8521
8809
  if (existing) killSession(id);
8522
8810
  const agentForDefs = opts.agent ?? "claude";
8523
- const customDefs = opts.command ? [] : customAgentsFor(projectId);
8811
+ const customDefs = opts.command ? [] : customAgentsFor(projectId, agentForDefs);
8524
8812
  const rosterBlock = agentForDefs === "codex" ? agentRosterBlock(customDefs) : "";
8525
8813
  let promptArg = "";
8526
8814
  if (!opts.command && opts.prompt) {
@@ -8665,7 +8953,7 @@ function capturePane(id, lines2 = 200) {
8665
8953
  return "";
8666
8954
  }
8667
8955
  }
8668
- async function sendToPane(id, text, chunkMs = 50) {
8956
+ async function sendToPane(id, text, chunkMs = 50, choices = []) {
8669
8957
  const p = getSession(id);
8670
8958
  if (!p || p.exited) return false;
8671
8959
  const line = text.replace(/\s*\r?\n\s*/g, " ").trim();
@@ -8675,6 +8963,10 @@ async function sendToPane(id, text, chunkMs = 50) {
8675
8963
  const screen = readDialogScreen(io.capture());
8676
8964
  if (screen?.kind === "review") return await submitReview(io, screen.submitRow);
8677
8965
  if (screen?.kind === "question") {
8966
+ if (screen.multi && screen.submit.present) {
8967
+ const pick2 = resolveChoices(choices, screen.options).choices.map((c) => c.index);
8968
+ return await answerMultiSelectDialog(io, { pick: pick2, line, freeIndex: screen.freeIndex }, chunkMs);
8969
+ }
8678
8970
  if (screen.freeIndex !== null) return await answerChoiceDialog(io, screen.freeIndex, line, chunkMs);
8679
8971
  if (screen.notes) return await answerNotesDialog(io, line, chunkMs);
8680
8972
  }
@@ -8969,9 +9261,9 @@ var init_pty = __esm({
8969
9261
  }
8970
9262
  };
8971
9263
  customAgentSource = () => [];
8972
- customAgentsFor = (projectId) => {
9264
+ customAgentsFor = (projectId, agent) => {
8973
9265
  try {
8974
- return customAgentSource(projectId);
9266
+ return customAgentSource(projectId, agent);
8975
9267
  } catch {
8976
9268
  return [];
8977
9269
  }
@@ -8994,6 +9286,11 @@ var init_pty = __esm({
8994
9286
  enter: () => {
8995
9287
  tmux("send-keys", "-t", name(id), "Enter");
8996
9288
  },
9289
+ // SPEC-485 · SATU panah per pemanggilan: terukur, empat `Down` dalam satu `send-keys` memindahkan
9290
+ // fokus satu baris saja (jebakan burst ADR-0085, kali ini pada tombol kendali).
9291
+ down: () => {
9292
+ tmux("send-keys", "-t", name(id), "Down");
9293
+ },
8997
9294
  sleep
8998
9295
  });
8999
9296
  DIALOG_CAPTURE_LINES = 60;
@@ -9273,7 +9570,10 @@ var init_sync = __esm({
9273
9570
  // ada: `upsert` yang tak menyebut kolom ber-default TETAP berhasil, jadi kolom yang terlewat
9274
9571
  // mendarat sebagai default palsu di tiap client tanpa satu pun error (kelas ADR-0090/0093).
9275
9572
  // `version` tak pernah masuk FIELDS — ia stempel mekanisme sync itu sendiri.
9276
- customAgent: ["projectId", "name", "description", "instructions", "tools", "model", "mentions", "enabled", "createdAt", "updatedAt"],
9573
+ // SPEC-484 · ADR-0101 · `runtime` ikut: ia menentukan sesi mesin mana yang memakai persona ini,
9574
+ // dan kolom yang terlewat di sini mendarat sebagai default palsu (= "warisi") di setiap mesin
9575
+ // lain tanpa satu pun error.
9576
+ customAgent: ["projectId", "name", "description", "instructions", "tools", "model", "mentions", "runtime", "enabled", "createdAt", "updatedAt"],
9277
9577
  // SPEC-471 · ADR-0095 · SELURUH kolom bermakna ikut. `status`/`specId` termasuk: keputusan
9278
9578
  // triase adalah bagian keadaan yang harus dilihat sama oleh semua mesin — tanpa itu satu
9279
9579
  // mesin bisa menerima ulang issue yang di mesin lain sudah jadi backlog.
@@ -9387,8 +9687,8 @@ var init_settings2 = __esm({
9387
9687
  // src/services/codex-trust.ts
9388
9688
  import { appendFileSync, mkdirSync as mkdirSync6, readFileSync as readFileSync5, realpathSync as realpathSync2 } from "node:fs";
9389
9689
  import { homedir as homedir2 } from "node:os";
9390
- function ensureCodexTrust(repoDir, home) {
9391
- const path = codexConfigPath(home);
9690
+ function ensureCodexTrust(repoDir, home2) {
9691
+ const path = codexConfigPath(home2);
9392
9692
  let canonical = repoDir;
9393
9693
  try {
9394
9694
  canonical = realpathSync2(repoDir);
@@ -9402,7 +9702,7 @@ function ensureCodexTrust(repoDir, home) {
9402
9702
  } catch {
9403
9703
  }
9404
9704
  if (existing.includes(header2)) return;
9405
- mkdirSync6(codexHome(home), { recursive: true });
9705
+ mkdirSync6(codexHome(home2), { recursive: true });
9406
9706
  const lead = existing === "" || existing.endsWith("\n") ? "" : "\n";
9407
9707
  appendFileSync(path, `${lead}
9408
9708
  ${header2}
@@ -9415,8 +9715,8 @@ var codexHome, codexConfigPath;
9415
9715
  var init_codex_trust = __esm({
9416
9716
  "src/services/codex-trust.ts"() {
9417
9717
  "use strict";
9418
- codexHome = (home) => home ?? process.env.CODEX_HOME ?? `${homedir2()}/.codex`;
9419
- codexConfigPath = (home) => `${codexHome(home)}/config.toml`;
9718
+ codexHome = (home2) => home2 ?? process.env.CODEX_HOME ?? `${homedir2()}/.codex`;
9719
+ codexConfigPath = (home2) => `${codexHome(home2)}/config.toml`;
9420
9720
  }
9421
9721
  });
9422
9722
 
@@ -9740,13 +10040,15 @@ var init_runtime = __esm({
9740
10040
 
9741
10041
  // src/services/telegram/gateway.ts
9742
10042
  import { createHash as createHash3 } from "node:crypto";
9743
- var digestRaw, TelegramGateway;
10043
+ var GATEWAY_PROGRESS_KIND, GATEWAY_FAILURE_KIND, digestRaw, TelegramGateway;
9744
10044
  var init_gateway = __esm({
9745
10045
  "src/services/telegram/gateway.ts"() {
9746
10046
  "use strict";
9747
10047
  init_client();
9748
10048
  init_protocol();
9749
10049
  init_runtime();
10050
+ GATEWAY_PROGRESS_KIND = "gateway-progress";
10051
+ GATEWAY_FAILURE_KIND = "gateway-failure";
9750
10052
  digestRaw = (update2) => createHash3("sha256").update(JSON.stringify(update2)).digest("hex");
9751
10053
  TelegramGateway = class {
9752
10054
  constructor(deps) {
@@ -9832,8 +10134,18 @@ var init_gateway = __esm({
9832
10134
  outcome: target2.created ? "session-created" : "session-reused",
9833
10135
  correlationId: `tg:${input.updateId}`
9834
10136
  });
10137
+ if (this.deps.progress) {
10138
+ await this.deps.store.enqueueReply({
10139
+ chatId: input.chatId,
10140
+ updateId: input.updateId,
10141
+ kind: GATEWAY_PROGRESS_KIND,
10142
+ text: target2.created ? "Diterima. Sesi operator Hanoman untuk chat ini sedang dijalankan \u2014 jawabannya menyusul." : "Diterima. Diteruskan ke sesi operator."
10143
+ }).catch(() => {
10144
+ });
10145
+ }
9835
10146
  updateTelegramRuntimeStatus({ lastUpdateAt: (/* @__PURE__ */ new Date()).toISOString(), lastError: null });
9836
10147
  } catch (error) {
10148
+ const reason = sanitizeTelegramOutput(error.message, this.deps.exactSecrets).slice(0, 200);
9837
10149
  await this.deps.store.markUpdateUncertain(input.updateId, "dispatch-failed");
9838
10150
  await this.deps.store.audit({
9839
10151
  chatId: input.chatId,
@@ -9843,7 +10155,14 @@ var init_gateway = __esm({
9843
10155
  outcome: "uncertain",
9844
10156
  correlationId: `tg:${input.updateId}`
9845
10157
  });
9846
- throw error;
10158
+ await this.deps.store.enqueueReply({
10159
+ chatId: input.chatId,
10160
+ updateId: input.updateId,
10161
+ kind: GATEWAY_FAILURE_KIND,
10162
+ text: `Pesan Anda tertangkap tapi gagal diteruskan ke sesi operator: ${reason || "sebab tak diketahui"}. Kirim ulang setelah masalahnya beres \u2014 hanoman tidak pernah mengulanginya sendiri (at-most-once).`
10163
+ }).catch(() => {
10164
+ });
10165
+ updateTelegramRuntimeStatus({ lastError: reason });
9847
10166
  }
9848
10167
  }
9849
10168
  async flushOutbox() {
@@ -9902,14 +10221,20 @@ var init_gateway = __esm({
9902
10221
  timeout: this.deps.pollTimeout ?? 25,
9903
10222
  signal
9904
10223
  });
9905
- await this.processUpdates(updates);
9906
- await this.flushOutbox();
10224
+ if (telegramRuntimeStatus().readiness === "error") {
10225
+ updateTelegramRuntimeStatus({ running: true, readiness: "running", lastError: null });
10226
+ }
10227
+ try {
10228
+ await this.processUpdates(updates);
10229
+ } finally {
10230
+ await this.flushOutbox();
10231
+ }
9907
10232
  } catch (error) {
9908
10233
  if (signal.aborted) return;
9909
10234
  const message = sanitizeTelegramOutput(error.message, this.deps.exactSecrets).slice(0, 500);
9910
10235
  updateTelegramRuntimeStatus({ running: false, readiness: "error", lastError: message });
9911
10236
  if (error instanceof TelegramApiError && error.code === 409) return;
9912
- await new Promise((resolve14) => setTimeout(resolve14, 1e3));
10237
+ await new Promise((resolve15) => setTimeout(resolve15, 1e3));
9913
10238
  }
9914
10239
  }
9915
10240
  }
@@ -9992,12 +10317,12 @@ ${input.text}`;
9992
10317
  });
9993
10318
 
9994
10319
  // src/services/telegram/store.ts
9995
- import { Prisma } from "@prisma/client";
10320
+ import { Prisma as Prisma3 } from "@prisma/client";
9996
10321
  var isUnique, TelegramStore;
9997
10322
  var init_store = __esm({
9998
10323
  "src/services/telegram/store.ts"() {
9999
10324
  "use strict";
10000
- isUnique = (error) => error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002";
10325
+ isUnique = (error) => error instanceof Prisma3.PrismaClientKnownRequestError && error.code === "P2002";
10001
10326
  TelegramStore = class {
10002
10327
  // SPEC-481 · klien yang diekspor kini ber-extension (tap webhook) sehingga tak lagi
10003
10328
  // assignable ke `PrismaClient` polos. `Db` diturunkan dari nilai nyatanya.
@@ -14362,7 +14687,7 @@ function syncStatus() {
14362
14687
  async function startSyncClient(base2, token, tickMs2) {
14363
14688
  started = true;
14364
14689
  const transport = fetchTransport(base2, token);
14365
- const tick5 = async () => {
14690
+ const tick6 = async () => {
14366
14691
  try {
14367
14692
  await syncOnce(transport);
14368
14693
  } catch {
@@ -14373,13 +14698,13 @@ async function startSyncClient(base2, token, tickMs2) {
14373
14698
  const wsUrl = base2.replace(/^http/, "ws").replace(/\/$/, "") + `/api/sync/ws?token=${encodeURIComponent(token)}`;
14374
14699
  ws = new WebSocket2(wsUrl);
14375
14700
  ws.on("open", () => {
14376
- void tick5();
14701
+ void tick6();
14377
14702
  });
14378
14703
  ws.on("message", async (raw) => {
14379
14704
  try {
14380
14705
  const msg = JSON.parse(raw.toString());
14381
14706
  if (msg.t !== "sync") return;
14382
- if (!await applyFeedFrame(msg)) void tick5();
14707
+ if (!await applyFeedFrame(msg)) void tick6();
14383
14708
  } catch {
14384
14709
  }
14385
14710
  });
@@ -14396,11 +14721,11 @@ async function startSyncClient(base2, token, tickMs2) {
14396
14721
  }
14397
14722
  });
14398
14723
  };
14399
- await tick5();
14724
+ await tick6();
14400
14725
  void connectWs();
14401
14726
  const ms = tickMs2 && tickMs2 > 0 ? tickMs2 : 15e3;
14402
14727
  timer2 = setInterval(() => {
14403
- void tick5();
14728
+ void tick6();
14404
14729
  }, ms);
14405
14730
  timer2.unref?.();
14406
14731
  }
@@ -16938,17 +17263,17 @@ var require_stream_wormhole = __commonJS({
16938
17263
  "../node_modules/.pnpm/stream-wormhole@1.1.0/node_modules/stream-wormhole/index.js"(exports, module) {
16939
17264
  "use strict";
16940
17265
  module.exports = (stream, throwError) => {
16941
- return new Promise((resolve14, reject2) => {
17266
+ return new Promise((resolve15, reject2) => {
16942
17267
  if (typeof stream.resume !== "function") {
16943
- return resolve14();
17268
+ return resolve15();
16944
17269
  }
16945
17270
  stream.unpipe && stream.unpipe();
16946
17271
  stream.resume();
16947
17272
  if (stream._readableState && stream._readableState.ended) {
16948
- return resolve14();
17273
+ return resolve15();
16949
17274
  }
16950
17275
  if (!stream.readable || stream.destroyed) {
16951
- return resolve14();
17276
+ return resolve15();
16952
17277
  }
16953
17278
  function cleanup() {
16954
17279
  stream.removeListener("end", onEnd);
@@ -16957,14 +17282,14 @@ var require_stream_wormhole = __commonJS({
16957
17282
  }
16958
17283
  function onEnd() {
16959
17284
  cleanup();
16960
- resolve14();
17285
+ resolve15();
16961
17286
  }
16962
17287
  function onError(err) {
16963
17288
  cleanup();
16964
17289
  if (throwError) {
16965
17290
  reject2(err);
16966
17291
  } else {
16967
- resolve14();
17292
+ resolve15();
16968
17293
  }
16969
17294
  }
16970
17295
  stream.on("end", onEnd);
@@ -17399,16 +17724,16 @@ var require_multipart2 = __commonJS({
17399
17724
  }
17400
17725
  };
17401
17726
  const parts = () => {
17402
- return new Promise((resolve14, reject2) => {
17727
+ return new Promise((resolve15, reject2) => {
17403
17728
  handle((val) => {
17404
17729
  if (val instanceof Error) {
17405
17730
  if (val.message === "Unexpected end of multipart data") {
17406
- resolve14(null);
17731
+ resolve15(null);
17407
17732
  } else {
17408
17733
  reject2(val);
17409
17734
  }
17410
17735
  } else {
17411
- resolve14(val);
17736
+ resolve15(val);
17412
17737
  }
17413
17738
  });
17414
17739
  });
@@ -17691,11 +18016,39 @@ function pickWebDir(distDir, env, exists) {
17691
18016
  return null;
17692
18017
  }
17693
18018
 
18019
+ // src/guide-file.ts
18020
+ import { resolve as resolve2 } from "node:path";
18021
+ var AGENT_DOC_REL = "docs/agent-integration.md";
18022
+ function pickGuideFile(distDir, env, exists) {
18023
+ const forced = env.HANOMAN_AGENT_DOC?.trim();
18024
+ if (forced) {
18025
+ if (!exists(forced)) throw new Error(`HANOMAN_AGENT_DOC menunjuk berkas yang tak ada: ${forced}`);
18026
+ return forced;
18027
+ }
18028
+ for (const c of [resolve2(distDir, "..", AGENT_DOC_REL), resolve2(distDir, "../..", AGENT_DOC_REL)]) {
18029
+ if (exists(c)) return c;
18030
+ }
18031
+ return null;
18032
+ }
18033
+
17694
18034
  // src/routes/health.ts
17695
18035
  async function health_default(app2) {
17696
18036
  app2.get("/health", async () => ({ ok: true }));
17697
18037
  }
17698
18038
 
18039
+ // src/routes/agent-doc.ts
18040
+ import { readFile } from "node:fs/promises";
18041
+ async function agent_doc_default(app2, opts) {
18042
+ app2.get("/agent-integration.md", async (_req, reply) => {
18043
+ if (!opts.file)
18044
+ return reply.code(404).send({
18045
+ error: `dokumen panduan tak ada di instalasi ini (${AGENT_DOC_REL}) \u2014 pasang ulang paket hanoman atau set HANOMAN_AGENT_DOC`
18046
+ });
18047
+ const text = await readFile(opts.file, "utf8");
18048
+ return reply.type("text/markdown; charset=utf-8").send(text);
18049
+ });
18050
+ }
18051
+
17699
18052
  // src/routes/projects.ts
17700
18053
  init_src();
17701
18054
  init_db();
@@ -17710,7 +18063,7 @@ init_src();
17710
18063
  import { execFile } from "node:child_process";
17711
18064
  import { promisify } from "node:util";
17712
18065
  import { readFileSync as readFileSync4, writeFileSync as writeFileSync3, existsSync as existsSync3, rmSync as rmSync2, mkdirSync as mkdirSync5 } from "node:fs";
17713
- import { resolve as resolve6, dirname as dirname6, sep } from "node:path";
18066
+ import { resolve as resolve7, dirname as dirname6, sep } from "node:path";
17714
18067
  var exec = promisify(execFile);
17715
18068
  async function listRepoDocs(repoDir) {
17716
18069
  try {
@@ -17726,7 +18079,7 @@ async function listRepoDocs(repoDir) {
17726
18079
  }
17727
18080
  function docsDirOf(repoDir) {
17728
18081
  try {
17729
- const raw = readFileSync4(resolve6(repoDir, "hanoman.config.json"), "utf8");
18082
+ const raw = readFileSync4(resolve7(repoDir, "hanoman.config.json"), "utf8");
17730
18083
  return zHanomanConfig.parse(JSON.parse(raw)).docsDir;
17731
18084
  } catch {
17732
18085
  return zHanomanConfig.parse({}).docsDir;
@@ -17734,7 +18087,7 @@ function docsDirOf(repoDir) {
17734
18087
  }
17735
18088
  function resolveIndex(repoDir, docsDir) {
17736
18089
  const rel = `${docsDir}/README.md`;
17737
- return existsSync3(resolve6(repoDir, rel)) ? rel : "";
18090
+ return existsSync3(resolve7(repoDir, rel)) ? rel : "";
17738
18091
  }
17739
18092
  var catOf = (rel) => rel.includes("/") ? rel.slice(0, rel.lastIndexOf("/")) : ".";
17740
18093
  var nameOf = (rel) => rel.includes("/") ? rel.slice(rel.lastIndexOf("/") + 1) : rel;
@@ -17745,7 +18098,7 @@ async function scanRepoDocs(repoDir) {
17745
18098
  const index = resolveIndex(repoDir, docsDir);
17746
18099
  const read = (rel) => {
17747
18100
  try {
17748
- return readFileSync4(resolve6(repoDir, rel), "utf8");
18101
+ return readFileSync4(resolve7(repoDir, rel), "utf8");
17749
18102
  } catch {
17750
18103
  return null;
17751
18104
  }
@@ -17768,7 +18121,7 @@ async function scanRepoDocs(repoDir) {
17768
18121
  function docAbsPath(repoDir, rel) {
17769
18122
  if (!rel.endsWith(".md")) throw new Error("hanya file .md yang diizinkan");
17770
18123
  if (rel.split("/").includes(".git")) throw new Error("tidak boleh menyentuh .git");
17771
- const abs = resolve6(repoDir, rel);
18124
+ const abs = resolve7(repoDir, rel);
17772
18125
  if (abs !== repoDir && !abs.startsWith(repoDir + sep)) throw new Error("path keluar dari repo");
17773
18126
  return abs;
17774
18127
  }
@@ -17819,6 +18172,7 @@ init_src();
17819
18172
 
17820
18173
  // src/services/project-view.ts
17821
18174
  init_pty();
18175
+ init_src();
17822
18176
  var IDLE = { status: "idle", phase: null, flow: null };
17823
18177
  function sessionOf(projectId, sessions) {
17824
18178
  const s2 = sessions.find((x) => x.projectId === projectId && x.specId && !x.exited);
@@ -17858,7 +18212,9 @@ async function toProjectView(p, sessions) {
17858
18212
  // SPEC-294 · opt-in scheduler otonom (lokal per-instance).
17859
18213
  schedulerOptIn: p.schedulerOptIn,
17860
18214
  // SPEC-409 · ADR-0091 · opt-in hanoman-lead (lokal per-instance, cermin schedulerOptIn).
17861
- leadOptIn: p.leadOptIn
18215
+ leadOptIn: p.leadOptIn,
18216
+ // SPEC-486 · ADR-0103 · kebijakan auto-merge (lokal per-instance, cermin schedulerOptIn).
18217
+ autoMerge: autoMergeOf(p.autoMerge)
17862
18218
  };
17863
18219
  }
17864
18220
 
@@ -17899,8 +18255,42 @@ async function branchFromCandidates(repoDir) {
17899
18255
  const [local, remote] = await Promise.all([listRepoBranches(repoDir), listRepoRemoteBranches(repoDir)]);
17900
18256
  return [.../* @__PURE__ */ new Set([...local, ...remote])].sort();
17901
18257
  }
18258
+ async function defaultBranch(repoDir) {
18259
+ if (!repoDir) return null;
18260
+ try {
18261
+ const { stdout } = await exec2(
18262
+ "git",
18263
+ ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"],
18264
+ { cwd: repoDir, ...GIT }
18265
+ );
18266
+ const name2 = stdout.trim().replace(/^origin\//, "");
18267
+ if (name2 && name2 !== "HEAD") return name2;
18268
+ } catch {
18269
+ }
18270
+ const all = /* @__PURE__ */ new Set([...await listRepoBranches(repoDir), ...await listRepoRemoteBranches(repoDir)]);
18271
+ for (const c of ["main", "master"]) if (all.has(c)) return c;
18272
+ return null;
18273
+ }
18274
+
18275
+ // src/services/auto-merge-gate.ts
18276
+ init_src();
18277
+ async function checkAutoMerge(repoDir, raw) {
18278
+ if (raw === null || raw === void 0) return { ok: true };
18279
+ const p = autoMergeOf(raw);
18280
+ if (!p) return { ok: false, code: 400, error: "bentuk kebijakan auto-merge tak sah" };
18281
+ if (p.mode === "off") return { ok: true };
18282
+ if (!repoDir)
18283
+ return { ok: false, code: 409, error: "project belum di-bind ke checkout lokal \u2014 atur repoDir dulu sebelum menyalakan auto-merge" };
18284
+ if (p.mode === "default-branch") {
18285
+ return await defaultBranch(repoDir) ? { ok: true } : { ok: false, code: 400, error: "default branch repo tak bisa diresolve (tak ada origin/HEAD, main, maupun master) \u2014 pilih branch tujuan secara eksplisit" };
18286
+ }
18287
+ if (!p.branch) return { ok: false, code: 400, error: 'mode "branch" butuh branch tujuan' };
18288
+ const known = p.dest === "origin" ? await listRepoRemoteBranches(repoDir) : await listRepoBranches(repoDir);
18289
+ return known.includes(p.branch) ? { ok: true } : { ok: false, code: 400, error: `branch "${p.branch}" tidak ada di ${p.dest === "origin" ? "origin" : "repo lokal"} project` };
18290
+ }
17902
18291
 
17903
18292
  // src/routes/projects.ts
18293
+ import { Prisma } from "@prisma/client";
17904
18294
  init_pty();
17905
18295
 
17906
18296
  // src/services/paginate.ts
@@ -17963,7 +18353,13 @@ async function projects_default(app2) {
17963
18353
  const parsed = zUpdateProject.safeParse(req.body);
17964
18354
  if (!parsed.success) return reply.code(400).send({ error: parsed.error.flatten() });
17965
18355
  if (!await prisma.project.findUnique({ where: { id } })) return reply.code(404).send({ error: "not found" });
17966
- const updated = await prisma.project.update({ where: { id }, data: parsed.data });
18356
+ if ("autoMerge" in parsed.data) {
18357
+ const gate = await checkAutoMerge(await resolveRepoDir(id), parsed.data.autoMerge);
18358
+ if (!gate.ok) return reply.code(gate.code).send({ error: gate.error });
18359
+ }
18360
+ const data = { ...parsed.data };
18361
+ if ("autoMerge" in data && data.autoMerge === null) data.autoMerge = Prisma.DbNull;
18362
+ const updated = await prisma.project.update({ where: { id }, data });
17967
18363
  await notifySynced("project", id);
17968
18364
  return toProjectView(updated, listSessions());
17969
18365
  });
@@ -17995,7 +18391,11 @@ async function projects_default(app2) {
17995
18391
  const p = await prisma.project.findUnique({ where: { id } });
17996
18392
  if (!p) return reply.code(404).send({ error: "not found" });
17997
18393
  const repoDir = await resolveRepoDir(id);
17998
- return { branches: await listRepoBranches(repoDir), remotes: await listRepoRemoteBranches(repoDir) };
18394
+ return {
18395
+ branches: await listRepoBranches(repoDir),
18396
+ remotes: await listRepoRemoteBranches(repoDir),
18397
+ defaultBranch: await defaultBranch(repoDir)
18398
+ };
17999
18399
  });
18000
18400
  app2.get("/projects/:id/help-center", async (req, reply) => {
18001
18401
  const { id } = req.params;
@@ -18121,6 +18521,9 @@ async function resolveGraphSource(repoDir, source) {
18121
18521
  if (await refExists(repoDir, cand)) return cand;
18122
18522
  return null;
18123
18523
  }
18524
+ async function discardMergeWorktree(repoDir, wt) {
18525
+ await reclaim(repoDir, wt);
18526
+ }
18124
18527
  async function deleteMergedBranch(repoDir, branch) {
18125
18528
  await sh(repoDir, ["branch", "-D", "--end-of-options", branch]);
18126
18529
  if (await refExists(repoDir, `refs/remotes/origin/${branch}`))
@@ -18218,9 +18621,9 @@ init_db();
18218
18621
  // src/services/spec-review.ts
18219
18622
  import { execFile as execFile4 } from "node:child_process";
18220
18623
  import { promisify as promisify4 } from "node:util";
18221
- import { readFile, mkdtemp, copyFile, rm } from "node:fs/promises";
18624
+ import { readFile as readFile2, mkdtemp, copyFile, rm } from "node:fs/promises";
18222
18625
  import { tmpdir as tmpdir2 } from "node:os";
18223
- import { join as join4, resolve as resolve7 } from "node:path";
18626
+ import { join as join4, resolve as resolve8 } from "node:path";
18224
18627
  var exec4 = promisify4(execFile4);
18225
18628
  var GIT3 = { maxBuffer: 1 << 24 };
18226
18629
  var MAX = 256 * 1024;
@@ -18229,7 +18632,7 @@ async function withTempIndex(wt, fn) {
18229
18632
  const idx = (await exec4("git", ["rev-parse", "--git-path", "index"], { cwd: wt, ...GIT3 })).stdout.trim();
18230
18633
  const dir2 = await mkdtemp(join4(tmpdir2(), "hanoman-idx-"));
18231
18634
  const tmp = join4(dir2, "index");
18232
- await copyFile(resolve7(wt, idx), tmp);
18635
+ await copyFile(resolve8(wt, idx), tmp);
18233
18636
  const env = { ...process.env, GIT_INDEX_FILE: tmp };
18234
18637
  try {
18235
18638
  await exec4("git", ["add", "-A", "-N"], { cwd: wt, env, ...GIT3 });
@@ -18246,9 +18649,9 @@ async function revOk(wt, rev) {
18246
18649
  return exec4("git", ["rev-parse", "--verify", "-q", "--end-of-options", `${rev}^{commit}`], { cwd: wt, ...GIT3 }).then(() => true).catch(() => false);
18247
18650
  }
18248
18651
  async function mergeBase(wt, baseSha, branchFrom) {
18249
- const candidates = [baseSha, branchFrom, "main", "master"].filter((c) => !!c);
18652
+ const candidates2 = [baseSha, branchFrom, "main", "master"].filter((c) => !!c);
18250
18653
  let base2 = "HEAD";
18251
- for (const c of candidates) if (await revOk(wt, c)) {
18654
+ for (const c of candidates2) if (await revOk(wt, c)) {
18252
18655
  base2 = c;
18253
18656
  break;
18254
18657
  }
@@ -18353,7 +18756,7 @@ async function reviewFile(repoDir, specId, baseSha, branchFrom, path) {
18353
18756
  let contentRaw = null;
18354
18757
  if (status !== "D") {
18355
18758
  try {
18356
- contentRaw = await readFile(join4(wt, path), "utf8");
18759
+ contentRaw = await readFile2(join4(wt, path), "utf8");
18357
18760
  } catch {
18358
18761
  contentRaw = null;
18359
18762
  }
@@ -18641,6 +19044,9 @@ async function validateDependsOn(specId, projectId, raw) {
18641
19044
  return { ok: true, ids };
18642
19045
  }
18643
19046
 
19047
+ // src/routes/specs.ts
19048
+ import { Prisma as Prisma2 } from "@prisma/client";
19049
+
18644
19050
  // src/services/doc-export.ts
18645
19051
  import PDFDocument from "pdfkit";
18646
19052
 
@@ -20963,7 +21369,7 @@ function renderDocPdf(text, name2, meta) {
20963
21369
  });
20964
21370
  const chunks = [];
20965
21371
  doc.on("data", (c) => chunks.push(c));
20966
- const done = new Promise((resolve14) => doc.on("end", () => resolve14(Buffer.concat(chunks))));
21372
+ const done = new Promise((resolve15) => doc.on("end", () => resolve15(Buffer.concat(chunks))));
20967
21373
  doc.font("Helvetica-Bold").fontSize(8).fillColor(BRASS).text(toWinAnsi(meta.eyebrow.toUpperCase()), { characterSpacing: 0.8 });
20968
21374
  doc.moveDown(0.25);
20969
21375
  doc.font("Helvetica-Bold").fontSize(17).fillColor(STRONG).text(toWinAnsi(name2.split("/").pop() ?? name2));
@@ -21065,6 +21471,13 @@ async function recordFailure(specId, title, projectId, reason) {
21065
21471
  }).catch(() => {
21066
21472
  });
21067
21473
  }
21474
+ async function recordAutoMerge(specId, projectId, title) {
21475
+ const sessionId2 = specId.toLowerCase().replace(/[^a-z0-9_-]/g, "_");
21476
+ await prisma.notification.create({
21477
+ data: { type: "automerge", key: `automerge:${specId}`, specId, sessionId: sessionId2, title, projectId }
21478
+ }).catch(() => {
21479
+ });
21480
+ }
21068
21481
  async function recordNewTicket(ticketId, projectId, projectName, category, title) {
21069
21482
  const short = title.length > 80 ? title.slice(0, 77) + "\u2026" : title;
21070
21483
  const t = `Keluhan baru di "${projectName}": ${category}: ${short}`;
@@ -21274,7 +21687,7 @@ ${item.context}` : item.context;
21274
21687
  if (!parsed.success) return reply.code(400).send({ error: parsed.error.flatten() });
21275
21688
  const spec = await prisma.spec.findUnique({ where: { id } });
21276
21689
  if (!spec) return reply.code(404).send({ error: "not found" });
21277
- const { branchFrom, stage, confirmDelete, title, priority: newPriority, payload, dependsOn } = parsed.data;
21690
+ const { branchFrom, stage, confirmDelete, title, priority: newPriority, payload, dependsOn, autoMerge } = parsed.data;
21278
21691
  const editingContent = title !== void 0 || newPriority !== void 0 || payload !== void 0;
21279
21692
  if (editingContent && (spec.stage !== "brainstorming" || spec.baseSha !== null))
21280
21693
  return reply.code(409).send({ error: "backlog item sudah dimulai \u2014 tak bisa diedit" });
@@ -21288,6 +21701,10 @@ ${item.context}` : item.context;
21288
21701
  if (!d.ok) return reply.code(400).send({ error: d.error });
21289
21702
  depIds = d.ids;
21290
21703
  }
21704
+ if ("autoMerge" in parsed.data) {
21705
+ const gate = await checkAutoMerge(await resolveRepoDir(spec.projectId), autoMerge);
21706
+ if (!gate.ok) return reply.code(gate.code).send({ error: gate.error });
21707
+ }
21291
21708
  if (stage !== void 0) {
21292
21709
  if (STAGES.indexOf(stage) >= STAGES.indexOf(spec.stage))
21293
21710
  return reply.code(422).send({ error: "stage hanya boleh dikembalikan mundur" });
@@ -21300,6 +21717,7 @@ ${item.context}` : item.context;
21300
21717
  const data = {};
21301
21718
  if (branchFrom !== void 0) data.branchFrom = branchFrom;
21302
21719
  if (depIds !== void 0) data.dependsOn = depIds;
21720
+ if ("autoMerge" in parsed.data) data.autoMerge = autoMerge === null ? Prisma2.DbNull : autoMerge;
21303
21721
  if (stage !== void 0) data.stage = stage;
21304
21722
  if (editingContent) {
21305
21723
  const effPayload = payload ?? spec.payload;
@@ -21660,16 +22078,16 @@ import { promisify as promisify7 } from "node:util";
21660
22078
  // src/services/git-ide.ts
21661
22079
  import { execFile as execFile6 } from "node:child_process";
21662
22080
  import { promisify as promisify6 } from "node:util";
21663
- import { readFile as readFile2, writeFile, mkdir } from "node:fs/promises";
22081
+ import { readFile as readFile3, writeFile, mkdir } from "node:fs/promises";
21664
22082
  import { existsSync as existsSync6 } from "node:fs";
21665
- import { resolve as resolve8, sep as sep2, dirname as dirname8 } from "node:path";
22083
+ import { resolve as resolve9, sep as sep2, dirname as dirname8 } from "node:path";
21666
22084
  var exec6 = promisify6(execFile6);
21667
22085
  var GIT5 = { maxBuffer: 1 << 24 };
21668
22086
  var MAX2 = 256 * 1024;
21669
22087
  var splitZ2 = (s2) => s2.split("\0").filter(Boolean);
21670
22088
  function repoAbsPath(repoDir, rel) {
21671
22089
  if (rel.split(/[\\/]/).includes(".git")) throw new Error("tidak boleh menyentuh .git");
21672
- const abs = resolve8(repoDir, rel);
22090
+ const abs = resolve9(repoDir, rel);
21673
22091
  if (abs !== repoDir && !abs.startsWith(repoDir + sep2)) throw new Error("path keluar dari repo");
21674
22092
  return abs;
21675
22093
  }
@@ -21687,7 +22105,7 @@ async function readRepoFile(repoDir, rel, ref = "") {
21687
22105
  repoAbsPath(repoDir, rel);
21688
22106
  let raw;
21689
22107
  try {
21690
- raw = ref ? (await exec6("git", ["show", `${ref}:${rel}`], { cwd: repoDir, ...GIT5 })).stdout : await readFile2(repoAbsPath(repoDir, rel), "utf8");
22108
+ raw = ref ? (await exec6("git", ["show", `${ref}:${rel}`], { cwd: repoDir, ...GIT5 })).stdout : await readFile3(repoAbsPath(repoDir, rel), "utf8");
21691
22109
  } catch {
21692
22110
  return null;
21693
22111
  }
@@ -22088,7 +22506,7 @@ async function workingFileDiff(repoDir, path, staged) {
22088
22506
  let contentRaw = null;
22089
22507
  if (cf.status !== "D") {
22090
22508
  try {
22091
- contentRaw = staged ? (await exec6("git", ["show", `:${path}`], { cwd: repoDir, ...GIT5 })).stdout : await readFile2(repoAbsPath(repoDir, path), "utf8");
22509
+ contentRaw = staged ? (await exec6("git", ["show", `:${path}`], { cwd: repoDir, ...GIT5 })).stdout : await readFile3(repoAbsPath(repoDir, path), "utf8");
22092
22510
  } catch {
22093
22511
  contentRaw = null;
22094
22512
  }
@@ -22554,11 +22972,11 @@ async function finishGraphOp(reply, id, repoDir, r, verb) {
22554
22972
  // src/routes/fs.ts
22555
22973
  import { readdir } from "node:fs/promises";
22556
22974
  import { homedir as homedir3 } from "node:os";
22557
- import { resolve as resolve9, dirname as dirname9, join as join5 } from "node:path";
22975
+ import { resolve as resolve10, dirname as dirname9, join as join5 } from "node:path";
22558
22976
  async function fs_default(app2) {
22559
22977
  app2.get("/fs/browse", async (req, reply) => {
22560
22978
  const q = req.query.path;
22561
- const dir2 = q && q.trim() ? resolve9(q.trim()) : homedir3();
22979
+ const dir2 = q && q.trim() ? resolve10(q.trim()) : homedir3();
22562
22980
  try {
22563
22981
  const ents = await readdir(dir2, { withFileTypes: true });
22564
22982
  const entries = ents.filter((e) => e.isDirectory() && !e.name.startsWith(".")).map((e) => ({ name: e.name, path: join5(dir2, e.name) })).sort((a, b) => a.name.localeCompare(b.name));
@@ -22694,11 +23112,11 @@ async function startSpecSession(spec, opts) {
22694
23112
  }
22695
23113
 
22696
23114
  // src/services/session-worktree.ts
22697
- import { resolve as resolve10, sep as sep3 } from "node:path";
23115
+ import { resolve as resolve11, sep as sep3 } from "node:path";
22698
23116
  function ownsWorktree(repoDir, cwd) {
22699
- const base2 = resolve10(repoDir);
22700
- const dir2 = resolve10(cwd);
22701
- const container = resolve10(base2, ".worktrees");
23117
+ const base2 = resolve11(repoDir);
23118
+ const dir2 = resolve11(cwd);
23119
+ const container = resolve11(base2, ".worktrees");
22702
23120
  return dir2 !== base2 && dir2 !== container && dir2.startsWith(container + sep3);
22703
23121
  }
22704
23122
 
@@ -23199,14 +23617,14 @@ function sshExec(t, remoteCmd, opts = {}) {
23199
23617
  SSH_ASKPASS_REQUIRE: "force",
23200
23618
  HANOMAN_SSH_PASSWORD: opts.password
23201
23619
  } : process.env;
23202
- return new Promise((resolve14) => {
23620
+ return new Promise((resolve15) => {
23203
23621
  const p = spawn3(sshBin(), args, { stdio: ["pipe", "pipe", "pipe"], env });
23204
23622
  let out3 = "";
23205
- const timer6 = setTimeout(() => p.kill("SIGKILL"), opts.timeoutMs ?? 6e4);
23623
+ const timer7 = setTimeout(() => p.kill("SIGKILL"), opts.timeoutMs ?? 6e4);
23206
23624
  const done = (r) => {
23207
- clearTimeout(timer6);
23625
+ clearTimeout(timer7);
23208
23626
  if (askpass) rmSync4(dirname10(askpass), { recursive: true, force: true });
23209
- resolve14(r);
23627
+ resolve15(r);
23210
23628
  };
23211
23629
  p.stdout.on("data", (d) => {
23212
23630
  out3 += d;
@@ -23983,7 +24401,7 @@ init_src();
23983
24401
  init_src();
23984
24402
  init_config2();
23985
24403
  import { readFileSync as readFileSync11 } from "node:fs";
23986
- import { resolve as resolve11, dirname as dirname12 } from "node:path";
24404
+ import { resolve as resolve12, dirname as dirname12 } from "node:path";
23987
24405
  import { fileURLToPath as fileURLToPath3 } from "node:url";
23988
24406
  var UPDATE_COMMAND = "npm i -g hanoman@latest";
23989
24407
  function composeUpdate(x) {
@@ -24009,7 +24427,7 @@ var lastLatest = null;
24009
24427
  var lastStatus = "unavailable";
24010
24428
  function runningVersion() {
24011
24429
  const here = dirname12(fileURLToPath3(import.meta.url));
24012
- for (const p of [resolve11(here, "build-info.json"), resolve11(here, "../package.json")]) {
24430
+ for (const p of [resolve12(here, "build-info.json"), resolve12(here, "../package.json")]) {
24013
24431
  try {
24014
24432
  const v = JSON.parse(readFileSync11(p, "utf8")).version;
24015
24433
  if (typeof v === "string" && v) return v;
@@ -24358,8 +24776,8 @@ init_conflicts();
24358
24776
  // src/services/uploads.ts
24359
24777
  init_config2();
24360
24778
  import { randomUUID as randomUUID3 } from "node:crypto";
24361
- import { mkdir as mkdir2, writeFile as writeFile2, readFile as readFile3, unlink } from "node:fs/promises";
24362
- import { join as join12, resolve as resolve12 } from "node:path";
24779
+ import { mkdir as mkdir2, writeFile as writeFile2, readFile as readFile4, unlink } from "node:fs/promises";
24780
+ import { join as join12, resolve as resolve13 } from "node:path";
24363
24781
  var EXT = {
24364
24782
  "image/png": ".png",
24365
24783
  "image/jpeg": ".jpg",
@@ -24369,7 +24787,7 @@ function extFor(mimeType) {
24369
24787
  return EXT[mimeType] ?? ".bin";
24370
24788
  }
24371
24789
  function uploadDir() {
24372
- return resolve12(effectiveStr("HANOMAN_UPLOAD_DIR") ?? join12(process.cwd(), "data", "uploads"));
24790
+ return resolve13(effectiveStr("HANOMAN_UPLOAD_DIR") ?? join12(process.cwd(), "data", "uploads"));
24373
24791
  }
24374
24792
  async function saveUpload(buf, mimeType) {
24375
24793
  const dir2 = uploadDir();
@@ -24380,13 +24798,13 @@ async function saveUpload(buf, mimeType) {
24380
24798
  }
24381
24799
  async function readUpload(storageKey) {
24382
24800
  const safe = storageKey.replace(/[/\\]/g, "");
24383
- return readFile3(join12(uploadDir(), safe));
24801
+ return readFile4(join12(uploadDir(), safe));
24384
24802
  }
24385
24803
  async function readUploadOrFetch(storageKey) {
24386
24804
  const safe = storageKey.replace(/[/\\]/g, "");
24387
24805
  const target2 = join12(uploadDir(), safe);
24388
24806
  try {
24389
- return await readFile3(target2);
24807
+ return await readFile4(target2);
24390
24808
  } catch {
24391
24809
  const base2 = effectiveStr("SYNC_SERVER_URL");
24392
24810
  const token = effectiveStr("SYNC_DEVICE_TOKEN");
@@ -24533,11 +24951,11 @@ import { randomUUID as randomUUID5 } from "node:crypto";
24533
24951
  // src/services/transcript-store.ts
24534
24952
  init_config2();
24535
24953
  import { randomUUID as randomUUID4 } from "node:crypto";
24536
- import { mkdir as mkdir3, writeFile as writeFile3, readFile as readFile4, unlink as unlink2 } from "node:fs/promises";
24537
- import { join as join13, resolve as resolve13, basename as basename2 } from "node:path";
24954
+ import { mkdir as mkdir3, writeFile as writeFile3, readFile as readFile5, unlink as unlink2 } from "node:fs/promises";
24955
+ import { join as join13, resolve as resolve14, basename as basename2 } from "node:path";
24538
24956
  var MAX_TRANSCRIPT_BYTES = 1024 * 1024;
24539
24957
  function transcriptDir() {
24540
- return resolve13(effectiveStr("HANOMAN_TRANSCRIPT_DIR") ?? join13(process.cwd(), "data", "transcripts"));
24958
+ return resolve14(effectiveStr("HANOMAN_TRANSCRIPT_DIR") ?? join13(process.cwd(), "data", "transcripts"));
24541
24959
  }
24542
24960
  function clamp(text) {
24543
24961
  const buf = Buffer.from(text, "utf8");
@@ -24561,7 +24979,7 @@ async function saveTranscript(text) {
24561
24979
  async function readTranscript(key) {
24562
24980
  if (!key) return null;
24563
24981
  try {
24564
- return await readFile4(join13(transcriptDir(), basename2(key)), "utf8");
24982
+ return await readFile5(join13(transcriptDir(), basename2(key)), "utf8");
24565
24983
  } catch {
24566
24984
  return null;
24567
24985
  }
@@ -25322,6 +25740,16 @@ function leadPrompt(q, c) {
25322
25740
  }
25323
25741
  lines2.push("");
25324
25742
  }
25743
+ if (c.chainSteps?.length) {
25744
+ lines2.push("## Rantai keputusan ini (langkah yang sudah dijawab)");
25745
+ for (const [i, s2] of c.chainSteps.entries()) {
25746
+ lines2.push(`${i + 1}. "${s2.question.slice(0, 200)}" \u2192 ${s2.picked.length ? s2.picked.join("; ") : "(tanpa pilihan)"}`);
25747
+ if (s2.options.length) lines2.push(` opsi saat itu: ${s2.options.map((o) => o.slice(0, 80)).join(" \xB7 ")}`);
25748
+ }
25749
+ lines2.push("");
25750
+ lines2.push("Pertanyaan di bawah adalah lanjutan dari rantai itu. Jangan bertentangan dengan yang sudah kamu putuskan di atas, dan jangan mengulang penjelasannya.");
25751
+ lines2.push("");
25752
+ }
25325
25753
  lines2.push("## Yang harus kamu putuskan");
25326
25754
  lines2.push(q.question.trim());
25327
25755
  if (q.options?.length) {
@@ -25329,7 +25757,12 @@ function leadPrompt(q, c) {
25329
25757
  lines2.push("Opsi yang dilihat peminta:");
25330
25758
  for (const [i, o] of q.options.entries()) lines2.push(`${i + 1}. ${o}`);
25331
25759
  lines2.push("");
25332
- lines2.push('Salah satu dari daftar itu WAJIB kamu pilih lewat field `choice` \u2014 isi nomornya ("2") atau labelnya persis. Pilihan di luar daftar ditolak server, dicatat sebagai penolakan, dan peminta kembali menunggu manusia.');
25760
+ if (c.select?.mode === "multi") {
25761
+ lines2.push(`Opsinya TIDAK saling eksklusif. Isi \`choices\` dengan daftar nomor atau label yang kamu pilih (mis. ["1","3"]) \u2014 paling sedikit ${c.select.min}, paling banyak ${c.select.max}. Jumlah di luar itu membuat SELURUH pilihanmu dibatalkan, bukan dipangkas.`);
25762
+ } else {
25763
+ lines2.push('Salah satu dari daftar itu WAJIB kamu pilih lewat field `choice` \u2014 isi nomornya ("2") atau labelnya persis.');
25764
+ }
25765
+ lines2.push("Pilihan di luar daftar ditolak server, dicatat sebagai penolakan, dan peminta kembali menunggu manusia.");
25333
25766
  }
25334
25767
  lines2.push("");
25335
25768
  lines2.push("## Batas waktu (BACA INI DULU)");
@@ -25356,6 +25789,7 @@ function leadPrompt(q, c) {
25356
25789
  lines2.push(JSON.stringify({
25357
25790
  decision: "keputusan yang dipilih, satu kalimat",
25358
25791
  choice: "nomor atau label opsi yang kamu pilih; kosongkan bila tak ada daftar opsi",
25792
+ choices: [],
25359
25793
  reason: "alasannya, dua-tiga kalimat, menyebut bukti",
25360
25794
  refs: ["internal/docs/\u2026", "ADR-00xx"],
25361
25795
  confidence: "tinggi | sedang | ragu",
@@ -25414,6 +25848,7 @@ function keepExistingRefs(refs2, repoDir) {
25414
25848
 
25415
25849
  // src/services/lead/trail.ts
25416
25850
  init_db();
25851
+ init_src();
25417
25852
  async function recordDecision(i) {
25418
25853
  return prisma.leadDecision.create({
25419
25854
  data: {
@@ -25436,6 +25871,11 @@ async function recordDecision(i) {
25436
25871
  // null eksplisit, sementara "tak disebut" pada create sudah berarti NULL di baris barunya.
25437
25872
  options: i.options?.length ? i.options : void 0,
25438
25873
  missing: i.missing?.length ? i.missing : void 0,
25874
+ // SPEC-485 · daftar kosong disimpan NULL dengan alasan yang sama seperti `options` di atas.
25875
+ choices: i.choices?.length ? i.choices : void 0,
25876
+ select: i.select ?? void 0,
25877
+ flowId: i.flowId ?? null,
25878
+ step: i.step ?? null,
25439
25879
  status: i.status ?? "berlaku",
25440
25880
  weighty: i.weighty ?? false,
25441
25881
  actor: i.actor ?? "lead"
@@ -25448,16 +25888,21 @@ async function listDecisions(f = {}) {
25448
25888
  ...f.projectId ? { projectId: f.projectId } : {},
25449
25889
  ...f.specId ? { specId: f.specId } : {},
25450
25890
  ...f.sessionId ? { sessionId: f.sessionId } : {},
25891
+ ...f.flowId ? { flowId: f.flowId } : {},
25451
25892
  ...f.status ? { status: f.status } : {}
25452
25893
  },
25453
- orderBy: { createdAt: "desc" },
25894
+ // SPEC-485 · satu RANTAI dibaca dari awal: urutan pertanyaannya adalah isi jejaknya. Daftar
25895
+ // umum tetap terbaru-dulu (AC-24) — dua pertanyaan yang berbeda, dua urutan yang berbeda.
25896
+ orderBy: { createdAt: f.flowId ? "asc" : "desc" },
25454
25897
  take: Math.min(f.take ?? 50, 200),
25455
25898
  skip: f.skip ?? 0
25456
25899
  });
25457
25900
  }
25458
- async function overrideDecision(id, answer, reason) {
25901
+ async function overrideDecision(id, answer, reason, rawChoices = []) {
25459
25902
  const old = await prisma.leadDecision.findUnique({ where: { id } });
25460
25903
  if (!old || old.status !== "berlaku") return null;
25904
+ const options2 = Array.isArray(old.options) ? old.options.map(String) : [];
25905
+ const { choices } = resolveChoices(rawChoices, options2);
25461
25906
  const next = await recordDecision({
25462
25907
  projectId: old.projectId,
25463
25908
  specId: old.specId,
@@ -25470,7 +25915,14 @@ async function overrideDecision(id, answer, reason) {
25470
25915
  refs: [],
25471
25916
  confidence: "tinggi",
25472
25917
  action: "none",
25473
- actor: "operator"
25918
+ actor: "operator",
25919
+ choices,
25920
+ choice: choices[0]?.option ?? null,
25921
+ choiceIndex: choices[0]?.index ?? null,
25922
+ options: options2,
25923
+ select: old.select,
25924
+ flowId: old.flowId,
25925
+ step: old.step
25474
25926
  });
25475
25927
  const updated = await prisma.leadDecision.update({
25476
25928
  where: { id },
@@ -25502,6 +25954,13 @@ function toDecisionView(r) {
25502
25954
  choiceIndex: r.choiceIndex,
25503
25955
  options: Array.isArray(r.options) ? r.options.map(String) : [],
25504
25956
  missing: Array.isArray(r.missing) ? r.missing.map(String) : [],
25957
+ // SPEC-485 · ADR-0102 · jawaban SELALU daftar di permukaan baca. Baris pra-migrasi tak punya
25958
+ // kolomnya, jadi ia DITURUNKAN dari pasangan skalar lama — itulah yang membuat riwayat lama
25959
+ // tetap terbaca sesudah perubahan skema, tanpa satu pun backfill.
25960
+ choices: Array.isArray(r.choices) ? r.choices.map((c) => ({ index: Number(c?.index ?? 0) || 1, option: String(c?.option ?? "") })).filter((c) => c.option !== "") : r.choice ? [{ index: r.choiceIndex ?? 1, option: r.choice }] : [],
25961
+ select: r.select ?? null,
25962
+ flowId: r.flowId,
25963
+ step: r.step,
25505
25964
  status: r.status,
25506
25965
  weighty: r.weighty,
25507
25966
  supersededById: r.supersededById,
@@ -25548,7 +26007,7 @@ function leadFailureReason(agent, timeoutMs, err, stdout, stderr) {
25548
26007
  function think(prompt, o) {
25549
26008
  const bin = binFor(o.agent);
25550
26009
  const args = leadArgv({ agent: o.agent, model: o.model, effort: o.effort, prompt });
25551
- return new Promise((resolve14, reject2) => {
26010
+ return new Promise((resolve15, reject2) => {
25552
26011
  const child = execFile9(bin, args, {
25553
26012
  cwd: o.cwd,
25554
26013
  timeout: o.timeoutMs,
@@ -25561,7 +26020,7 @@ function think(prompt, o) {
25561
26020
  reject2(new Error(leadFailureReason(o.agent, o.timeoutMs, err, stdout, stderr)));
25562
26021
  return;
25563
26022
  }
25564
- resolve14(stdout);
26023
+ resolve15(stdout);
25565
26024
  });
25566
26025
  child.stdin?.end();
25567
26026
  });
@@ -25598,9 +26057,9 @@ function acquire(cap, waitMs) {
25598
26057
  return Promise.resolve();
25599
26058
  }
25600
26059
  const startedAt = Date.now();
25601
- return new Promise((resolve14, reject2) => {
26060
+ return new Promise((resolve15, reject2) => {
25602
26061
  const w = {
25603
- grant: resolve14,
26062
+ grant: resolve15,
25604
26063
  deny: reject2,
25605
26064
  timer: setTimeout(() => {
25606
26065
  const i = queue.indexOf(w);
@@ -25629,6 +26088,98 @@ async function runPool(items, limit, fn) {
25629
26088
  await Promise.all(Array.from({ length: workers }, worker));
25630
26089
  }
25631
26090
 
26091
+ // src/services/lead/flow.ts
26092
+ init_src();
26093
+ init_db();
26094
+ var LeadFlowClosedError = class extends Error {
26095
+ constructor(flowId, flowStatus) {
26096
+ super(`rantai keputusan ${flowId} sudah ${flowStatus === "tak-ada" ? "tidak ada" : flowStatus}`);
26097
+ this.flowId = flowId;
26098
+ this.flowStatus = flowStatus;
26099
+ this.name = "LeadFlowClosedError";
26100
+ }
26101
+ };
26102
+ var isOpen = (s2) => LEAD_FLOW_OPEN.includes(s2);
26103
+ var flowTitle = (q) => q.replace(/\s+/g, " ").trim().slice(0, 200);
26104
+ async function openFlow(i) {
26105
+ return prisma.leadFlow.create({
26106
+ data: {
26107
+ projectId: i.projectId,
26108
+ specId: i.specId ?? null,
26109
+ sessionId: i.sessionId ?? null,
26110
+ gate: i.gate,
26111
+ title: flowTitle(i.title),
26112
+ expiresAt: new Date(Date.now() + Math.max(1, i.ttlMin) * 6e4)
26113
+ }
26114
+ });
26115
+ }
26116
+ async function joinFlow(id) {
26117
+ const row = await prisma.leadFlow.findUnique({ where: { id } });
26118
+ if (!row) throw new LeadFlowClosedError(id, "tak-ada");
26119
+ if (!isOpen(row.status)) throw new LeadFlowClosedError(id, row.status);
26120
+ return row;
26121
+ }
26122
+ async function markFlowStep(id, answered) {
26123
+ const row = await prisma.leadFlow.findUnique({ where: { id } });
26124
+ if (!row || !isOpen(row.status)) return;
26125
+ await prisma.leadFlow.update({
26126
+ where: { id },
26127
+ data: {
26128
+ steps: { increment: 1 },
26129
+ ...answered && row.status === "menunggu" ? { status: "sebagian" } : {}
26130
+ }
26131
+ });
26132
+ }
26133
+ async function closeFlow(id, reason) {
26134
+ const row = await prisma.leadFlow.findUnique({ where: { id } });
26135
+ if (!row || !isOpen(row.status)) return null;
26136
+ const status = reason === "operator" || reason === "kedaluwarsa" ? "dibatalkan" : "selesai";
26137
+ return prisma.leadFlow.update({
26138
+ where: { id },
26139
+ data: { status, closeReason: reason, closedAt: /* @__PURE__ */ new Date() }
26140
+ });
26141
+ }
26142
+ async function listFlows(f = {}) {
26143
+ return prisma.leadFlow.findMany({
26144
+ where: {
26145
+ ...f.projectId ? { projectId: f.projectId } : {},
26146
+ ...f.status ? { status: f.status } : {}
26147
+ },
26148
+ orderBy: { createdAt: "desc" },
26149
+ take: Math.min(f.take ?? 50, 200),
26150
+ skip: f.skip ?? 0
26151
+ });
26152
+ }
26153
+ async function expireFlows(now) {
26154
+ const due = await prisma.leadFlow.findMany({
26155
+ where: { status: { in: [...LEAD_FLOW_OPEN] }, expiresAt: { lt: now } },
26156
+ orderBy: { createdAt: "asc" },
26157
+ take: 100
26158
+ });
26159
+ const out3 = [];
26160
+ for (const f of due) {
26161
+ const closed = await closeFlow(f.id, "kedaluwarsa");
26162
+ if (closed) out3.push(closed);
26163
+ }
26164
+ return out3;
26165
+ }
26166
+ function toFlowView(r) {
26167
+ return {
26168
+ id: r.id,
26169
+ projectId: r.projectId,
26170
+ specId: r.specId,
26171
+ sessionId: r.sessionId,
26172
+ gate: r.gate,
26173
+ status: r.status,
26174
+ title: r.title,
26175
+ steps: r.steps,
26176
+ closeReason: r.closeReason,
26177
+ openedAt: r.openedAt.toISOString(),
26178
+ closedAt: r.closedAt ? r.closedAt.toISOString() : null,
26179
+ expiresAt: r.expiresAt.toISOString()
26180
+ };
26181
+ }
26182
+
25632
26183
  // src/services/lead/decide.ts
25633
26184
  var prodDecideDeps = {
25634
26185
  think,
@@ -25652,6 +26203,15 @@ var notifTitle = (kind, question, answer, confidence) => {
25652
26203
  async function decide(req, deps = prodDecideDeps) {
25653
26204
  const cfg = await getLead();
25654
26205
  if (!leadActive(cfg, req.projectId)) return null;
26206
+ const flow = req.flowId ? await joinFlow(req.flowId) : await openFlow({
26207
+ projectId: req.projectId,
26208
+ specId: req.specId,
26209
+ sessionId: req.sessionId,
26210
+ gate: req.gate,
26211
+ title: req.question,
26212
+ ttlMin: cfg.flowTtlMin
26213
+ });
26214
+ const step = flow.steps + 1;
25655
26215
  const project = await prisma.project.findUnique({ where: { id: req.projectId }, select: { name: true } });
25656
26216
  const spec = req.specId ? await prisma.spec.findUnique({
25657
26217
  where: { id: req.specId },
@@ -25663,6 +26223,9 @@ async function decide(req, deps = prodDecideDeps) {
25663
26223
  orderBy: { createdAt: "desc" },
25664
26224
  take: 10
25665
26225
  });
26226
+ const chainRows = req.flowId ? await prisma.leadDecision.findMany({ where: { flowId: flow.id }, orderBy: { createdAt: "asc" }, take: 10 }) : [];
26227
+ const options2 = req.options ?? [];
26228
+ const bounds = normalizeSelect(req.select ?? { mode: "single", min: 0, max: null }, options2.length);
25666
26229
  const ctx = {
25667
26230
  projectId: req.projectId,
25668
26231
  projectName: project?.name ?? req.projectId,
@@ -25679,7 +26242,13 @@ async function decide(req, deps = prodDecideDeps) {
25679
26242
  reason: d.reason,
25680
26243
  createdAt: d.createdAt.toISOString()
25681
26244
  })),
25682
- notes: req.notes
26245
+ notes: req.notes,
26246
+ select: bounds,
26247
+ chainSteps: chainRows.map((d) => ({
26248
+ question: d.question,
26249
+ options: Array.isArray(d.options) ? d.options.map(String) : [],
26250
+ picked: Array.isArray(d.choices) ? d.choices.map((c) => String(c?.option ?? "")).filter(Boolean) : d.choice ? [d.choice] : []
26251
+ }))
25683
26252
  };
25684
26253
  const { agent, model, effort } = await deps.defaults();
25685
26254
  const prompt = leadPrompt({ kind: req.kind, question: req.question, options: req.options }, ctx);
@@ -25698,24 +26267,30 @@ async function decide(req, deps = prodDecideDeps) {
25698
26267
  }
25699
26268
  });
25700
26269
  } catch (e) {
25701
- if (e instanceof LeadBusyError) throw e;
25702
- return fail(req, deps, `lead tak menghasilkan keputusan: ${e.message}`);
26270
+ if (e instanceof LeadBusyError) {
26271
+ if (!req.flowId) await closeFlow(flow.id, "kedaluwarsa").catch(() => null);
26272
+ throw e;
26273
+ }
26274
+ return closeAfter(await fail(req, deps, `lead tak menghasilkan keputusan: ${e.message}`, flow.id, step), flow.id, req.chain);
25703
26275
  } finally {
25704
26276
  if (req.sessionId) clearQueued(req.sessionId);
25705
26277
  }
25706
26278
  const verdict = parseLeadVerdict(raw);
25707
- if (!verdict) return fail(req, deps, "keluaran lead tak memuat blok json keputusan yang sah");
26279
+ if (!verdict) return closeAfter(await fail(req, deps, "keluaran lead tak memuat blok json keputusan yang sah", flow.id, step), flow.id, req.chain);
25708
26280
  const refs2 = keepExistingRefs(verdict.refs, repoDir);
25709
26281
  const allowed = leadActionAllowed(verdict.action);
25710
26282
  const kind = allowed ? req.kind : "refusal";
25711
- const options2 = req.options ?? [];
25712
- const choice = resolveChoice(verdict.choice, options2);
25713
- const choiceRejected = options2.length > 0 && verdict.choice.trim() !== "" && !choice;
26283
+ const rawChoices = verdict.choices.length ? verdict.choices : verdict.choice.trim() ? [verdict.choice] : [];
26284
+ const resolved = resolveChoices(rawChoices, options2);
26285
+ const countProblem = options2.length && resolved.choices.length ? checkChoiceCount(resolved.choices.length, bounds) : null;
26286
+ const choices = countProblem ? [] : resolved.choices;
26287
+ const choice = choices[0] ?? null;
26288
+ const choiceRejected = options2.length > 0 && rawChoices.length > 0 && (resolved.rejected.length > 0 || !!countProblem);
25714
26289
  const missing = verdict.missing.map((m) => m.trim()).filter(Boolean);
25715
26290
  let action = allowed ? verdict.action : "none";
25716
26291
  let actionNote = "";
25717
26292
  let conflict = false;
25718
- if (allowed && choice) {
26293
+ if (allowed && choices.length === 1 && choice) {
25719
26294
  const hint = optionActionHint(choice.option);
25720
26295
  if (hint && action === "none" && hint !== "none") {
25721
26296
  action = hint;
@@ -25729,7 +26304,10 @@ async function decide(req, deps = prodDecideDeps) {
25729
26304
  const confidence = missing.length ? "ragu" : verdict.confidence;
25730
26305
  const notes = [];
25731
26306
  if (!allowed) notes.push(`DITOLAK: ${leadRefusalReason(verdict.action)} berada di luar permukaan tindakan lead (ADR-0091 \xB7 AC-31/32).`);
25732
- if (choiceRejected) notes.push(`DITOLAK: pilihan "${verdict.choice.trim().slice(0, 120)}" tidak ada di daftar opsi yang dikirim peminta (SPEC-480 \xB7 ADR-0098).`);
26307
+ if (options2.length && resolved.rejected.length)
26308
+ notes.push(`DITOLAK: pilihan ${resolved.rejected.map((r) => `"${r.slice(0, 120)}"`).join(", ")} tidak ada di daftar opsi yang dikirim peminta (SPEC-480 \xB7 ADR-0098).`);
26309
+ if (countProblem)
26310
+ notes.push(`DITOLAK: ${countProblem} (SPEC-485 \xB7 ADR-0102) \u2014 seluruh pilihan dibatalkan, bukan dipangkas.`);
25733
26311
  if (actionNote) notes.push(actionNote);
25734
26312
  if (missing.length) notes.push(`KONTEKS KURANG: ${missing.join("; ")}`);
25735
26313
  const tail3 = notes.length ? `
@@ -25754,9 +26332,14 @@ ${notes.join("\n\n")}` : "";
25754
26332
  weighty,
25755
26333
  choice: choice?.option ?? null,
25756
26334
  choiceIndex: choice?.index ?? null,
26335
+ choices,
26336
+ select: bounds,
26337
+ flowId: flow.id,
26338
+ step,
25757
26339
  options: options2,
25758
26340
  missing
25759
26341
  });
26342
+ await closeAfter(row, flow.id, req.chain);
25760
26343
  if (weighty) {
25761
26344
  await deps.notify(
25762
26345
  row.id,
@@ -25770,18 +26353,24 @@ ${notes.join("\n\n")}` : "";
25770
26353
  decision: clampProse(verdict.decision, LEAD_DECISION_MAX),
25771
26354
  reason: `${clampProse(verdict.reason, LEAD_REASON_MAX)}${tail3}`,
25772
26355
  reply: verdict.reply,
26356
+ choices,
25773
26357
  choice,
25774
26358
  missing
25775
26359
  });
25776
26360
  return row;
25777
26361
  }
26362
+ async function closeAfter(row, flowId, chain) {
26363
+ await markFlowStep(flowId, row.status === "berlaku");
26364
+ if (!chain) await closeFlow(flowId, "tunggal");
26365
+ return row;
26366
+ }
25778
26367
  var lastDelivery = /* @__PURE__ */ new Map();
25779
26368
  function takeDelivery(decisionId) {
25780
26369
  const v = lastDelivery.get(decisionId) ?? null;
25781
26370
  lastDelivery.delete(decisionId);
25782
26371
  return v;
25783
26372
  }
25784
- async function fail(req, deps, reason) {
26373
+ async function fail(req, deps, reason, flowId = null, step = null) {
25785
26374
  const prev = await prisma.leadDecision.findFirst({
25786
26375
  where: { projectId: req.projectId, gate: req.gate, kind: req.kind },
25787
26376
  orderBy: { createdAt: "desc" },
@@ -25799,6 +26388,10 @@ async function fail(req, deps, reason) {
25799
26388
  refs: [],
25800
26389
  confidence: "ragu",
25801
26390
  action: "none",
26391
+ // SPEC-485 · langkah yang gagal tetap duduk di alurnya: alur yang seluruh langkahnya gagal
26392
+ // harus tetap terbaca sebagai satu urusan, bukan sebagai baris yatim.
26393
+ flowId,
26394
+ step,
25802
26395
  status: "gagal",
25803
26396
  weighty: true
25804
26397
  });
@@ -25983,6 +26576,7 @@ var CODEX_FINISHED = [
25983
26576
  /\btokens used\b/i,
25984
26577
  /\bTo continue this session\b/i
25985
26578
  ];
26579
+ var AGENT_TURN_LINE = /(?:\bfor\s+\d+\s*[hms]\b|\(\s*\d+\s*[hms][^)]*·)/i;
25986
26580
  var ASK_SIGNALS = [
25987
26581
  /\?\s*$/m,
25988
26582
  /^\s*\[?\d+[).\]]\s+\S/m,
@@ -25991,9 +26585,12 @@ var ASK_SIGNALS = [
25991
26585
  function readPaneQuestion(text, agent) {
25992
26586
  const lines2 = tail2(text, 40);
25993
26587
  const body = lines2.join("\n").trim();
25994
- const choices = readChoiceDialog(text)?.options ?? [];
26588
+ const dialog = readChoiceDialog(text);
26589
+ const choices = dialog?.options ?? [];
25995
26590
  if (!body) return { asking: false, question: "", reason: "layar kosong", choices };
25996
26591
  const question = tail2(text, 25).join("\n").trim();
26592
+ if (!dialog && AGENT_TURN_LINE.test(body))
26593
+ return { asking: false, question, reason: "giliran agen, bukan pertanyaan (baris giliran claude di layar)", choices };
25997
26594
  if (agent === "codex") {
25998
26595
  const finished = CODEX_FINISHED.find((re) => re.test(body));
25999
26596
  if (finished) return { asking: false, question, reason: "sesi codex selesai wajar (ADR-0074)", choices };
@@ -26008,14 +26605,20 @@ var answers = /* @__PURE__ */ new Map();
26008
26605
  var capped = /* @__PURE__ */ new Set();
26009
26606
  var failures = /* @__PURE__ */ new Map();
26010
26607
  var failCapped = /* @__PURE__ */ new Set();
26608
+ var failedAt = /* @__PURE__ */ new Map();
26609
+ var FAIL_COOLDOWN_MS = 15 * 6e4;
26610
+ var chainFlows = /* @__PURE__ */ new Map();
26011
26611
  var MAX_CHAIN_STEPS = 6;
26012
26612
  var CHAIN_POLL_MS = 300;
26013
26613
  var CHAIN_POLL_TRIES = 20;
26614
+ var CHAIN_END_TRIES = 5;
26014
26615
  function resetSession(sessionId2) {
26015
26616
  answers.delete(sessionId2);
26016
26617
  capped.delete(sessionId2);
26017
26618
  failures.delete(sessionId2);
26018
26619
  failCapped.delete(sessionId2);
26620
+ failedAt.delete(sessionId2);
26621
+ chainFlows.delete(sessionId2);
26019
26622
  }
26020
26623
  var prodDetectDeps = {
26021
26624
  live: () => {
@@ -26044,7 +26647,7 @@ var prodDetectDeps = {
26044
26647
  return true;
26045
26648
  }
26046
26649
  },
26047
- send: (id, text) => sendToPane(id, text),
26650
+ send: (id, text, choices) => sendToPane(id, text, 50, choices),
26048
26651
  clearMarker: (file) => {
26049
26652
  try {
26050
26653
  writeFileSync5(file, "");
@@ -26053,6 +26656,12 @@ var prodDetectDeps = {
26053
26656
  },
26054
26657
  submit: (id) => submitPaneDialog(id),
26055
26658
  sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
26659
+ // Alur yang gagal ditutup bukan alasan menggagalkan rantai yang sudah berhasil: penyapu TTL
26660
+ // (ADR-0102) tetap menutupnya.
26661
+ closeChain: async (flowId) => {
26662
+ await closeFlow(flowId, "submit").catch(() => null);
26663
+ },
26664
+ now: Date.now,
26056
26665
  decide,
26057
26666
  decideDeps: prodDecideDeps,
26058
26667
  delivery: takeDelivery,
@@ -26078,7 +26687,7 @@ async function scanAndAnswer(deps = prodDetectDeps) {
26078
26687
  skip("lead dijeda untuk project ini");
26079
26688
  continue;
26080
26689
  }
26081
- if (!deps.filled(s2.decisionFile)) continue;
26690
+ if (!deps.filled(s2.decisionFile) && !(!deps.exited(s2.id) && readDialogScreen(deps.pane(s2.id)))) continue;
26082
26691
  if (deps.exited(s2.id)) {
26083
26692
  skip("pane mati");
26084
26693
  continue;
@@ -26111,6 +26720,12 @@ async function scanAndAnswer(deps = prodDetectDeps) {
26111
26720
  skip("batas jawaban otomatis tercapai");
26112
26721
  continue;
26113
26722
  }
26723
+ const lastFail = failedAt.get(s2.id);
26724
+ if (lastFail !== void 0 && deps.now() - lastFail > FAIL_COOLDOWN_MS) {
26725
+ failures.delete(s2.id);
26726
+ failCapped.delete(s2.id);
26727
+ failedAt.delete(s2.id);
26728
+ }
26114
26729
  if ((failures.get(s2.id) ?? 0) >= cfg.maxAutoAnswers) {
26115
26730
  if (!failCapped.has(s2.id)) {
26116
26731
  failCapped.add(s2.id);
@@ -26156,15 +26771,27 @@ async function scanAndAnswer(deps = prodDetectDeps) {
26156
26771
  out3.answered.push(s2.id);
26157
26772
  return;
26158
26773
  }
26159
- if (chain.failed) failures.set(s2.id, (failures.get(s2.id) ?? 0) + 1);
26774
+ if (chain.failed) {
26775
+ failures.set(s2.id, (failures.get(s2.id) ?? 0) + 1);
26776
+ failedAt.set(s2.id, deps.now());
26777
+ }
26160
26778
  out3.skipped.push({ id: s2.id, reason: chain.reason });
26161
26779
  });
26162
26780
  return out3;
26163
26781
  }
26164
26782
  async function runChain(s2, agent, deps) {
26165
26783
  let acted = false;
26784
+ const done = async (r) => {
26785
+ const flowId = chainFlows.get(s2.id);
26786
+ if (flowId) {
26787
+ chainFlows.delete(s2.id);
26788
+ await deps.closeChain(flowId).catch(() => {
26789
+ });
26790
+ }
26791
+ return r;
26792
+ };
26166
26793
  for (let step = 0; step < MAX_CHAIN_STEPS; step++) {
26167
- const text = deps.pane(s2.id);
26794
+ const text = step === 0 ? deps.pane(s2.id) : await settledPane(s2.id, deps);
26168
26795
  const screen = readDialogScreen(text);
26169
26796
  if (screen?.kind === "review") {
26170
26797
  if (!await deps.submit(s2.id))
@@ -26172,10 +26799,10 @@ async function runChain(s2, agent, deps) {
26172
26799
  acted = true;
26173
26800
  continue;
26174
26801
  }
26175
- if (step > 0 && !screen) return { acted, done: true, failed: false, reason: "" };
26802
+ if (step > 0 && !screen) return done({ acted, done: true, failed: false, reason: "" });
26176
26803
  const read = readPaneQuestion(text, agent);
26177
26804
  if (!read.asking) {
26178
- return step === 0 ? { acted, done: false, failed: false, reason: read.reason } : { acted, done: true, failed: false, reason: "" };
26805
+ return step === 0 ? { acted, done: false, failed: false, reason: read.reason } : done({ acted, done: true, failed: false, reason: "" });
26179
26806
  }
26180
26807
  const notes = [`Sesi ini menunggu di terminal; teks di bawah adalah layar terakhirnya. Jawablah sebagai masukan yang bisa langsung diketik ke terminal itu (isi \`reply\`).`];
26181
26808
  if (read.choices.length) {
@@ -26187,36 +26814,55 @@ async function runChain(s2, agent, deps) {
26187
26814
  `Dialog ini BERANTAI: ${screen.tabs.length} pertanyaan dalam satu tanya (${screen.tabs.map((t) => `${t.answered ? "sudah" : "belum"}: ${t.header}`).join(", ")}). Yang sedang tampil pertanyaan ke-${at + 1}; jawab HANYA pertanyaan itu \u2014 sisanya akan ditanyakan sesudah ini.`
26188
26815
  );
26189
26816
  }
26817
+ const ask = {
26818
+ projectId: s2.projectId,
26819
+ specId: s2.specId,
26820
+ sessionId: s2.id,
26821
+ gate: "detected",
26822
+ kind: "answer",
26823
+ question: read.question,
26824
+ options: read.choices.length ? read.choices : void 0,
26825
+ notes,
26826
+ chain: true
26827
+ };
26190
26828
  let row;
26191
26829
  try {
26192
- row = await deps.decide({
26193
- projectId: s2.projectId,
26194
- specId: s2.specId,
26195
- sessionId: s2.id,
26196
- gate: "detected",
26197
- kind: "answer",
26198
- question: read.question,
26199
- options: read.choices.length ? read.choices : void 0,
26200
- notes
26201
- }, deps.decideDeps);
26830
+ row = await deps.decide({ ...ask, flowId: chainFlows.get(s2.id) ?? null }, deps.decideDeps);
26202
26831
  } catch (e) {
26203
26832
  if (e instanceof LeadBusyError) return { acted, done: false, failed: false, reason: `lead penuh \u2014 ${e.message}` };
26204
- throw e;
26833
+ if (!(e instanceof LeadFlowClosedError)) throw e;
26834
+ chainFlows.delete(s2.id);
26835
+ try {
26836
+ row = await deps.decide({ ...ask, flowId: null }, deps.decideDeps);
26837
+ } catch (e2) {
26838
+ if (e2 instanceof LeadBusyError) return { acted, done: false, failed: false, reason: `lead penuh \u2014 ${e2.message}` };
26839
+ throw e2;
26840
+ }
26205
26841
  }
26842
+ if (row?.flowId) chainFlows.set(s2.id, row.flowId);
26206
26843
  if (!row) return { acted, done: false, failed: false, reason: "lead tak menghasilkan keputusan yang berlaku" };
26207
26844
  if (row.status !== "berlaku")
26208
26845
  return { acted, done: false, failed: true, reason: "lead tak menghasilkan keputusan yang berlaku" };
26209
26846
  const sent = deps.delivery(row.id);
26210
26847
  const reply = (sent ? leadReplyText(sent) : "") || row.answer;
26211
- if (!await deps.send(s2.id, reply))
26848
+ const picked = sent?.choices.map((c) => c.option) ?? [];
26849
+ if (!await deps.send(s2.id, reply, picked))
26212
26850
  return { acted, done: false, failed: true, reason: "gagal mengetik ke pane" };
26213
26851
  acted = true;
26214
- if (!screen) return { acted, done: true, failed: false, reason: "" };
26852
+ if (!screen) return done({ acted, done: true, failed: false, reason: "" });
26215
26853
  if (!await waitScreenChange(s2.id, dialogKey(text), deps))
26216
26854
  return { acted, done: false, failed: true, reason: "layar dialog tak berubah sesudah dijawab" };
26217
26855
  }
26218
26856
  return { acted, done: false, failed: true, reason: "batas langkah rantai dialog tercapai" };
26219
26857
  }
26858
+ async function settledPane(id, deps) {
26859
+ let text = deps.pane(id);
26860
+ for (let i = 0; i < CHAIN_END_TRIES && !readDialogScreen(text); i++) {
26861
+ await deps.sleep(CHAIN_POLL_MS);
26862
+ text = deps.pane(id);
26863
+ }
26864
+ return text;
26865
+ }
26220
26866
  async function waitScreenChange(id, before, deps) {
26221
26867
  for (let i = 0; i < CHAIN_POLL_TRIES; i++) {
26222
26868
  await deps.sleep(CHAIN_POLL_MS);
@@ -26230,6 +26876,8 @@ function sweep(liveIds) {
26230
26876
  for (const id of [...capped]) if (!live.has(id)) capped.delete(id);
26231
26877
  for (const id of [...failures.keys()]) if (!live.has(id)) failures.delete(id);
26232
26878
  for (const id of [...failCapped]) if (!live.has(id)) failCapped.delete(id);
26879
+ for (const id of [...failedAt.keys()]) if (!live.has(id)) failedAt.delete(id);
26880
+ for (const id of [...chainFlows.keys()]) if (!live.has(id)) chainFlows.delete(id);
26233
26881
  }
26234
26882
 
26235
26883
  // src/services/lead/pulse.ts
@@ -26531,6 +27179,21 @@ async function tick2(now, deps = {}) {
26531
27179
  busyDetect = false;
26532
27180
  }));
26533
27181
  }
27182
+ jobs.push((async () => {
27183
+ const expire = deps.expire ?? expireFlows;
27184
+ const notify = deps.notify ?? recordLeadDecision;
27185
+ for (const f of await expire(/* @__PURE__ */ new Date())) {
27186
+ await notify(
27187
+ f.id,
27188
+ `Rantai keputusan lead ditutup karena kedaluwarsa: ${f.title.slice(0, 80)}`,
27189
+ f.projectId,
27190
+ f.specId,
27191
+ f.sessionId
27192
+ );
27193
+ }
27194
+ })().catch((e) => {
27195
+ console.error("lead expire:", e);
27196
+ }));
26534
27197
  if (!cfg.paused && !busyPulse && now - Math.max(lastPulseAt, pulseEndedAt) >= cfg.everyMin * 6e4) {
26535
27198
  busyPulse = true;
26536
27199
  lastPulseAt = now;
@@ -26618,11 +27281,35 @@ async function lead_default(app2) {
26618
27281
  specId: q.specId,
26619
27282
  sessionId: q.sessionId,
26620
27283
  status: q.status,
27284
+ // SPEC-485 · satu rantai dibaca lewat filter ini, urut NAIK (lihat `listDecisions`).
27285
+ flowId: q.flowId,
26621
27286
  take: q.take ? Number(q.take) : void 0,
26622
27287
  skip: q.skip ? Number(q.skip) : void 0
26623
27288
  });
26624
27289
  return { items: rows.map(toDecisionView) };
26625
27290
  });
27291
+ app2.get("/lead/flows", async (req) => {
27292
+ const q = req.query;
27293
+ const rows = await listFlows({
27294
+ projectId: q.projectId,
27295
+ status: q.status,
27296
+ take: q.take ? Number(q.take) : void 0,
27297
+ skip: q.skip ? Number(q.skip) : void 0
27298
+ });
27299
+ return { items: rows.map(toFlowView) };
27300
+ });
27301
+ app2.post("/lead/flows/:id/submit", async (req, reply) => {
27302
+ const { id } = req.params;
27303
+ const row = await closeFlow(id, "submit");
27304
+ if (!row) return reply.code(409).send({ error: "rantai tak ada atau sudah tertutup" });
27305
+ return toFlowView(row);
27306
+ });
27307
+ app2.post("/lead/flows/:id/cancel", async (req, reply) => {
27308
+ const { id } = req.params;
27309
+ const row = await closeFlow(id, "operator");
27310
+ if (!row) return reply.code(409).send({ error: "rantai tak ada atau sudah tertutup" });
27311
+ return toFlowView(row);
27312
+ });
26626
27313
  app2.post("/lead/decisions", async (req, reply) => {
26627
27314
  const parsed = zLeadAsk.safeParse(req.body);
26628
27315
  if (!parsed.success) return reply.code(400).send({ error: parsed.error.flatten() });
@@ -26632,6 +27319,15 @@ async function lead_default(app2) {
26632
27319
  const cfg = await getLead();
26633
27320
  if (!project.leadOptIn || !leadActive(cfg, ask.projectId))
26634
27321
  return reply.code(409).send({ error: "lead tidak aktif untuk project ini" });
27322
+ const optionCount = ask.options.length;
27323
+ if (ask.select.mode === "multi" && optionCount === 0)
27324
+ return reply.code(400).send({ error: "select.mode `multi` menuntut daftar `options`" });
27325
+ if (ask.select.max !== null && ask.select.min > ask.select.max)
27326
+ return reply.code(400).send({ error: "select.min melebihi select.max" });
27327
+ if (ask.select.max !== null && optionCount > 0 && ask.select.max > optionCount)
27328
+ return reply.code(400).send({ error: `select.max (${ask.select.max}) melebihi jumlah opsi (${optionCount})` });
27329
+ if (ask.select.min > optionCount)
27330
+ return reply.code(400).send({ error: `select.min (${ask.select.min}) melebihi jumlah opsi (${optionCount})` });
26635
27331
  let row;
26636
27332
  try {
26637
27333
  row = await decide({
@@ -26642,9 +27338,13 @@ async function lead_default(app2) {
26642
27338
  kind: "answer",
26643
27339
  question: ask.question,
26644
27340
  options: ask.options,
26645
- notes: ask.context ? [ask.context] : void 0
27341
+ notes: ask.context ? [ask.context] : void 0,
27342
+ select: ask.select,
27343
+ chain: ask.chain,
27344
+ flowId: ask.flowId ?? null
26646
27345
  });
26647
27346
  } catch (e) {
27347
+ if (e instanceof LeadFlowClosedError) return reply.code(409).send({ error: e.message });
26648
27348
  if (!(e instanceof LeadBusyError)) throw e;
26649
27349
  return reply.code(503).header("retry-after", String(Math.max(5, cfg.queueWaitSec))).send({ error: e.message, retryable: true, queued: e.queued });
26650
27350
  }
@@ -26657,6 +27357,7 @@ async function lead_default(app2) {
26657
27357
  } catch {
26658
27358
  }
26659
27359
  }
27360
+ const flowStatus = row.flowId ? (await prisma.leadFlow.findUnique({ where: { id: row.flowId }, select: { status: true } }))?.status ?? null : null;
26660
27361
  const answer = {
26661
27362
  id: row.id,
26662
27363
  decision: sent?.decision ?? row.answer,
@@ -26666,7 +27367,12 @@ async function lead_default(app2) {
26666
27367
  action: row.action,
26667
27368
  // Saluran pengiriman bisa meleset (baris lahir dari jalur lain); kolomnya yang selalu ada.
26668
27369
  choice: sent?.choice ?? (row.choice ? { index: row.choiceIndex ?? 1, option: row.choice } : null),
26669
- missing: sent?.missing ?? (Array.isArray(row.missing) ? row.missing.map(String) : [])
27370
+ missing: sent?.missing ?? (Array.isArray(row.missing) ? row.missing.map(String) : []),
27371
+ // SPEC-485 · `choices` bentuk yang berlaku; `toDecisionView` sudah tahu cara menurunkannya
27372
+ // dari kolom lama, jadi pemetaannya tak diduplikasi di sini (kelas bug SPEC-431/448/475).
27373
+ choices: sent?.choices ?? toDecisionView(row).choices,
27374
+ flowId: row.flowId,
27375
+ flowStatus
26670
27376
  };
26671
27377
  return reply.code(201).send(answer);
26672
27378
  });
@@ -26674,12 +27380,17 @@ async function lead_default(app2) {
26674
27380
  const { id } = req.params;
26675
27381
  const parsed = zLeadOverride.safeParse(req.body);
26676
27382
  if (!parsed.success) return reply.code(400).send({ error: parsed.error.flatten() });
26677
- const r = await overrideDecision(id, parsed.data.answer, parsed.data.reason);
27383
+ const r = await overrideDecision(id, parsed.data.answer, parsed.data.reason, parsed.data.choices);
26678
27384
  if (!r) return reply.code(409).send({ error: "keputusan tak ada atau sudah tak berlaku" });
26679
27385
  let delivered = false;
26680
27386
  if (r.next.sessionId) {
26681
27387
  resetSession(r.next.sessionId);
26682
- delivered = await sendToPane(r.next.sessionId, parsed.data.answer).catch(() => false);
27388
+ delivered = await sendToPane(
27389
+ r.next.sessionId,
27390
+ parsed.data.answer,
27391
+ 50,
27392
+ toDecisionView(r.next).choices.map((c) => c.option)
27393
+ ).catch(() => false);
26683
27394
  }
26684
27395
  return { old: toDecisionView(r.old), next: toDecisionView(r.next), delivered };
26685
27396
  });
@@ -26696,11 +27407,61 @@ async function lead_default(app2) {
26696
27407
  init_src();
26697
27408
  init_db();
26698
27409
 
27410
+ // src/services/agent-tool-catalog.ts
27411
+ init_src();
27412
+ import { readFileSync as readFileSync12 } from "node:fs";
27413
+ import { homedir as homedir8 } from "node:os";
27414
+ import { join as join15 } from "node:path";
27415
+ var home = () => process.env.HOME || homedir8();
27416
+ var readJson = (path) => {
27417
+ try {
27418
+ return JSON.parse(readFileSync12(path, "utf8"));
27419
+ } catch {
27420
+ return null;
27421
+ }
27422
+ };
27423
+ var serversOf = (node) => {
27424
+ const ms = node?.mcpServers;
27425
+ if (!ms || typeof ms !== "object" || Array.isArray(ms)) return [];
27426
+ return Object.keys(ms);
27427
+ };
27428
+ var codexServers = () => {
27429
+ let text;
27430
+ try {
27431
+ text = readFileSync12(join15(home(), ".codex", "config.toml"), "utf8");
27432
+ } catch {
27433
+ return [];
27434
+ }
27435
+ const out3 = [];
27436
+ for (const m of text.matchAll(/^\s*\[mcp_servers\.(?:"([^"]+)"|([A-Za-z0-9_-]+))(?:\.[^\]]*)?\]/gm)) {
27437
+ const name2 = m[1] ?? m[2];
27438
+ if (name2) out3.push(name2);
27439
+ }
27440
+ return out3;
27441
+ };
27442
+ function mcpServerNames(repoDir) {
27443
+ const names = [];
27444
+ const claudeJson = readJson(join15(home(), ".claude.json"));
27445
+ names.push(...serversOf(claudeJson));
27446
+ if (repoDir) {
27447
+ const projects = claudeJson?.projects;
27448
+ if (projects && typeof projects === "object") names.push(...serversOf(projects[repoDir]));
27449
+ names.push(...serversOf(readJson(join15(repoDir, ".mcp.json"))));
27450
+ }
27451
+ names.push(...codexServers());
27452
+ return [...new Set(names.filter(Boolean))].sort((a, b) => a.localeCompare(b));
27453
+ }
27454
+ function agentToolCatalog(repoDir) {
27455
+ return [ALL_TOOLS_ENTRY, ...BUILTIN_AGENT_TOOLS, ...mcpServerNames(repoDir).map(mcpToolEntry)];
27456
+ }
27457
+ var agentToolIds = (repoDir) => agentToolCatalog(repoDir).map((t) => t.id);
27458
+
26699
27459
  // src/services/custom-agents.ts
26700
27460
  init_db();
26701
27461
  init_src();
26702
27462
  init_pty();
26703
27463
  var cache4 = [];
27464
+ var repoDirCache = /* @__PURE__ */ new Map();
26704
27465
  var asCustomAgent = (r) => ({
26705
27466
  id: r.id,
26706
27467
  projectId: r.projectId,
@@ -26710,6 +27471,7 @@ var asCustomAgent = (r) => ({
26710
27471
  tools: toolsOf(r.tools),
26711
27472
  model: r.model,
26712
27473
  mentions: mentionsOf(r.mentions),
27474
+ runtime: runtimeOf(r.runtime),
26713
27475
  enabled: r.enabled,
26714
27476
  createdAt: "",
26715
27477
  updatedAt: ""
@@ -26718,18 +27480,31 @@ var asCustomAgent = (r) => ({
26718
27480
  async function loadCustomAgents() {
26719
27481
  try {
26720
27482
  cache4 = await prisma.customAgent.findMany();
27483
+ const projects = await prisma.project.findMany({ select: { id: true, repoDir: true } });
27484
+ const bindings = await prisma.localBinding.findMany({ select: { projectId: true, repoDir: true } });
27485
+ const next = /* @__PURE__ */ new Map();
27486
+ for (const p of projects) next.set(p.id, p.repoDir ?? null);
27487
+ for (const b of bindings) next.set(b.projectId, b.repoDir ?? null);
27488
+ repoDirCache = next;
26721
27489
  } catch {
26722
27490
  cache4 = [];
27491
+ repoDirCache = /* @__PURE__ */ new Map();
26723
27492
  }
26724
27493
  }
26725
- function agentDefsFor(projectId) {
27494
+ function agentDefsFor(projectId, agent) {
26726
27495
  const globals = cache4.filter((r) => r.projectId === null).map(asCustomAgent);
26727
27496
  const project = cache4.filter((r) => r.projectId === projectId).map(asCustomAgent);
26728
- return effectiveAgents(globals, project).map((a) => ({
27497
+ const eff = effectiveAgents(globals, project).filter((a) => a.runtime === null || a.runtime === agent);
27498
+ const needsCatalog = eff.some((a) => (a.tools ?? []).includes(ALL_TOOLS));
27499
+ const catalogIds = needsCatalog ? agentToolIds(repoDirCache.get(projectId) ?? null) : [];
27500
+ return eff.map((a) => ({
26729
27501
  name: a.name,
26730
27502
  description: a.description,
26731
27503
  instructions: a.instructions,
26732
- tools: a.tools,
27504
+ // Ekspansi terjadi DI SINI, sebelum `resolveTools` di runner: meneruskan `"*"` apa adanya
27505
+ // membuat claude membuangnya senyap (agen tanpa alat), sementara menerjemahkannya jadi `null`
27506
+ // membuat agen mewarisi SELURUH tool termasuk `Task` — lapis 2 anti-loop lenyap tanpa jejak.
27507
+ tools: expandTools(a.tools, catalogIds),
26733
27508
  model: a.model,
26734
27509
  mentions: a.mentions ?? []
26735
27510
  }));
@@ -26753,7 +27528,7 @@ function unknownMentions(row, all) {
26753
27528
  }
26754
27529
  async function installCustomAgents() {
26755
27530
  await loadCustomAgents();
26756
- registerCustomAgentSource((projectId) => agentDefsFor(projectId));
27531
+ registerCustomAgentSource((projectId, agent) => agentDefsFor(projectId, agent));
26757
27532
  }
26758
27533
 
26759
27534
  // src/routes/custom-agents.ts
@@ -26767,10 +27542,33 @@ var view6 = (r, projectId) => ({
26767
27542
  tools: toolsOf(r.tools),
26768
27543
  model: r.model,
26769
27544
  mentions: mentionsOf(r.mentions),
27545
+ runtime: runtimeOf(r.runtime),
26770
27546
  enabled: r.enabled,
26771
27547
  ...projectId ? { inherited: r.projectId === null } : {}
26772
27548
  });
27549
+ var repoDirOf2 = async (projectId) => projectId ? await resolveRepoDir(projectId) : null;
27550
+ function toolsProblem(tools, catalogIds) {
27551
+ if (!tools || tools.length === 0) return null;
27552
+ if (tools.includes(ALL_TOOLS) && tools.length > 1) {
27553
+ return { error: "pintasan * harus jadi satu-satunya pilihan tools", unknownTools: [] };
27554
+ }
27555
+ const unknownTools = tools.filter((t) => !catalogIds.includes(t));
27556
+ return unknownTools.length ? { error: "tool tak dikenal di mesin ini", unknownTools } : null;
27557
+ }
27558
+ function modelProblem(model, runtime) {
27559
+ if (!model) return null;
27560
+ const ok2 = modelsForRuntime(runtime).some((m) => m.id === model);
27561
+ return ok2 ? null : { error: "model tak dikenal untuk runtime ini", model, runtime };
27562
+ }
26773
27563
  async function custom_agents_default(app2) {
27564
+ app2.get("/custom-agents/catalog", async (req) => {
27565
+ const projectId = req.query.projectId;
27566
+ return {
27567
+ tools: agentToolCatalog(await repoDirOf2(projectId)),
27568
+ models: modelsForRuntime(null),
27569
+ runtimes: AGENT_RUNTIMES.map((id) => ({ id, label: AGENT_RUNTIME_LABELS[id] }))
27570
+ };
27571
+ });
26774
27572
  app2.get("/custom-agents", async (req) => {
26775
27573
  const projectId = req.query.projectId;
26776
27574
  const rows = await prisma.customAgent.findMany({
@@ -26789,6 +27587,10 @@ async function custom_agents_default(app2) {
26789
27587
  const projectId = p.projectId ?? null;
26790
27588
  if (projectId && !await prisma.project.findUnique({ where: { id: projectId } }))
26791
27589
  return reply.code(400).send({ error: "project tak ditemukan", projectId });
27590
+ const tp = toolsProblem(p.tools ?? null, agentToolIds(await repoDirOf2(projectId)));
27591
+ if (tp) return reply.code(400).send(tp);
27592
+ const mp = modelProblem(p.model ?? null, p.runtime ?? null);
27593
+ if (mp) return reply.code(400).send(mp);
26792
27594
  const id = customAgentId(projectId, p.name);
26793
27595
  if (await prisma.customAgent.findUnique({ where: { id } }))
26794
27596
  return reply.code(409).send({ error: "nama sudah dipakai di scope ini", id });
@@ -26801,6 +27603,7 @@ async function custom_agents_default(app2) {
26801
27603
  tools: p.tools ?? null,
26802
27604
  model: p.model ?? null,
26803
27605
  mentions: p.mentions ?? [],
27606
+ runtime: p.runtime ?? null,
26804
27607
  enabled: p.enabled ?? true
26805
27608
  };
26806
27609
  const all = [...await rowsOf(), candidate];
@@ -26817,6 +27620,7 @@ async function custom_agents_default(app2) {
26817
27620
  tools: candidate.tools,
26818
27621
  model: candidate.model,
26819
27622
  mentions: candidate.mentions,
27623
+ runtime: candidate.runtime,
26820
27624
  enabled: candidate.enabled
26821
27625
  } });
26822
27626
  await loadCustomAgents();
@@ -26833,11 +27637,24 @@ async function custom_agents_default(app2) {
26833
27637
  const existing = await prisma.customAgent.findUnique({ where: { id } });
26834
27638
  if (!existing) return reply.code(404).send({ error: "not found" });
26835
27639
  const before = existing;
27640
+ const effRuntime = "runtime" in parsed.data ? parsed.data.runtime ?? null : runtimeOf(before.runtime);
27641
+ if (parsed.data.tools !== void 0) {
27642
+ const tp = toolsProblem(parsed.data.tools, agentToolIds(await repoDirOf2(before.projectId)));
27643
+ if (tp) return reply.code(400).send(tp);
27644
+ }
27645
+ if (parsed.data.model !== void 0 || "runtime" in parsed.data) {
27646
+ const mp = modelProblem(
27647
+ parsed.data.model !== void 0 ? parsed.data.model : before.model,
27648
+ effRuntime
27649
+ );
27650
+ if (mp) return reply.code(400).send(mp);
27651
+ }
26836
27652
  const candidate = {
26837
27653
  ...before,
26838
27654
  ...parsed.data,
26839
27655
  mentions: parsed.data.mentions ?? mentionsOf(before.mentions),
26840
- tools: parsed.data.tools !== void 0 ? parsed.data.tools : toolsOf(before.tools)
27656
+ tools: parsed.data.tools !== void 0 ? parsed.data.tools : toolsOf(before.tools),
27657
+ runtime: effRuntime
26841
27658
  };
26842
27659
  const all = (await rowsOf()).map((r) => r.id === id ? candidate : r);
26843
27660
  const unknown = unknownMentions(candidate, all);
@@ -26850,6 +27667,7 @@ async function custom_agents_default(app2) {
26850
27667
  tools: candidate.tools,
26851
27668
  model: candidate.model,
26852
27669
  mentions: candidate.mentions,
27670
+ runtime: candidate.runtime,
26853
27671
  enabled: candidate.enabled
26854
27672
  } });
26855
27673
  await loadCustomAgents();
@@ -26900,24 +27718,24 @@ function githubSlugFromUrl(url2) {
26900
27718
  async function resolveGithubRepo(projectId) {
26901
27719
  const project = await prisma.project.findUnique({ where: { id: projectId } });
26902
27720
  if (!project) return { ok: false, kind: "no-project", error: `project "${projectId}" tidak ada` };
26903
- const candidates = [];
26904
- if (project.gitRemote) candidates.push(project.gitRemote);
27721
+ const candidates2 = [];
27722
+ if (project.gitRemote) candidates2.push(project.gitRemote);
26905
27723
  const repoDir = await resolveRepoDir(projectId).catch(() => null);
26906
27724
  if (repoDir) {
26907
27725
  const origin = (await listRemotes(repoDir)).find((r) => r.name === "origin");
26908
- if (origin?.fetch) candidates.push(origin.fetch);
27726
+ if (origin?.fetch) candidates2.push(origin.fetch);
26909
27727
  }
26910
- if (candidates.length === 0)
27728
+ if (candidates2.length === 0)
26911
27729
  return {
26912
27730
  ok: false,
26913
27731
  kind: "no-remote",
26914
27732
  error: "project belum punya remote GitHub (isi gitRemote atau tambahkan origin di repo lokalnya)"
26915
27733
  };
26916
- for (const url2 of candidates) {
27734
+ for (const url2 of candidates2) {
26917
27735
  const gh = githubSlugFromUrl(url2);
26918
27736
  if (gh) return { ok: true, repo: gh };
26919
27737
  }
26920
- const host2 = parse2(candidates[0])?.host ?? candidates[0];
27738
+ const host2 = parse2(candidates2[0])?.host ?? candidates2[0];
26921
27739
  return {
26922
27740
  ok: false,
26923
27741
  kind: "not-github",
@@ -26967,7 +27785,7 @@ function issuesFromRest(raw) {
26967
27785
  }
26968
27786
  var GH_FIELDS = "number,title,body,author,labels,url,state,createdAt,updatedAt";
26969
27787
  var API2 = "https://api.github.com";
26970
- var defaultRunGh = (args, env) => new Promise((resolve14, reject2) => {
27788
+ var defaultRunGh = (args, env) => new Promise((resolve15, reject2) => {
26971
27789
  execFile10(
26972
27790
  args[0],
26973
27791
  args.slice(1),
@@ -26975,7 +27793,7 @@ var defaultRunGh = (args, env) => new Promise((resolve14, reject2) => {
26975
27793
  (err, stdout, stderr) => {
26976
27794
  const e = err;
26977
27795
  if (e && (e.code === "ENOENT" || e.code === "EACCES")) return reject2(e);
26978
- resolve14({ code: err ? Number(err.code ?? 1) : 0, stdout, stderr });
27796
+ resolve15({ code: err ? Number(err.code ?? 1) : 0, stdout, stderr });
26979
27797
  }
26980
27798
  );
26981
27799
  });
@@ -27294,8 +28112,42 @@ import { randomBytes as randomBytes5 } from "node:crypto";
27294
28112
  // src/services/telegram/credentials.ts
27295
28113
  init_src();
27296
28114
  init_config2();
28115
+ init_agent_token();
28116
+ init_settings2();
28117
+ init_bootstrap();
27297
28118
  init_client();
27298
28119
  init_protocol();
28120
+ init_runtime();
28121
+ async function verifyTelegramAgentToken(token, deps = {}) {
28122
+ const agent = await (deps.verify ?? verifyAgentToken)(token);
28123
+ if (!agent) {
28124
+ return {
28125
+ ok: false,
28126
+ missing: [...TELEGRAM_REQUIRED_CAPABILITIES],
28127
+ reason: "AgentToken tidak dikenal atau sudah dicabut \u2014 salin plaintext `hnm_agt_\u2026` dari Akses AI Agent, bukan hash-nya."
28128
+ };
28129
+ }
28130
+ const missing = TELEGRAM_REQUIRED_CAPABILITIES.filter((c) => !agent.capabilities.includes(c));
28131
+ return missing.length ? { ok: false, missing, reason: `AgentToken kurang ${missing.length} capability.` } : { ok: true };
28132
+ }
28133
+ async function telegramInboundReadiness(deps = {}) {
28134
+ const polling = deps.polling ?? telegramRuntimeStatus().running;
28135
+ const empty = [];
28136
+ const token = deps.agentToken ?? effectiveStr("HANOMAN_TELEGRAM_AGENT_TOKEN")?.trim();
28137
+ if (!token) {
28138
+ return { ok: false, reason: "AgentToken gateway belum diisi.", missingCapabilities: [...TELEGRAM_REQUIRED_CAPABILITIES], polling };
28139
+ }
28140
+ const setting = await (deps.getSetting ?? getSetting)();
28141
+ if (!setting.agentAccessEnabled) {
28142
+ return { ok: false, reason: "Akses agent mati \u2014 nyalakan master switch di Akses AI Agent.", missingCapabilities: empty, polling };
28143
+ }
28144
+ const gate = await verifyTelegramAgentToken(token, deps);
28145
+ if (!gate.ok) return { ok: false, reason: gate.reason, missingCapabilities: gate.missing, polling };
28146
+ if (!polling) {
28147
+ return { ok: false, reason: "Kredensial sudah sah tapi gateway belum polling \u2014 nyalakan \u201CGateway aktif\u201D.", missingCapabilities: empty, polling };
28148
+ }
28149
+ return { ok: true, reason: null, missingCapabilities: empty, polling };
28150
+ }
27299
28151
  var KEYS = TELEGRAM_CONFIG_KEYS;
27300
28152
  function telegramCredentialView() {
27301
28153
  return {
@@ -27313,7 +28165,7 @@ function telegramCredentialView() {
27313
28165
  })
27314
28166
  };
27315
28167
  }
27316
- async function saveTelegramCredentials(patch) {
28168
+ async function saveTelegramCredentials(patch, deps = {}) {
27317
28169
  const writes = [];
27318
28170
  for (const [key, raw] of Object.entries(patch)) {
27319
28171
  if (!KEYS.includes(key)) return { ok: false, key, error: "key bukan kredensial Telegram" };
@@ -27327,6 +28179,14 @@ async function saveTelegramCredentials(patch) {
27327
28179
  if (!parsed.ok) return { ok: false, key, error: parsed.error };
27328
28180
  writes.push([key, parsed.value]);
27329
28181
  }
28182
+ const agentToken = writes.find(([key]) => key === "HANOMAN_TELEGRAM_AGENT_TOKEN")?.[1];
28183
+ if (agentToken !== void 0) {
28184
+ const gate = await verifyTelegramAgentToken(agentToken, deps);
28185
+ if (!gate.ok) {
28186
+ const detail = gate.missing.length < TELEGRAM_REQUIRED_CAPABILITIES.length ? `${gate.reason} Kurang: ${gate.missing.join(", ")}.` : gate.reason;
28187
+ return { ok: false, key: "HANOMAN_TELEGRAM_AGENT_TOKEN", error: detail };
28188
+ }
28189
+ }
27330
28190
  for (const [key, value] of writes) await setConfig(key, value);
27331
28191
  return { ok: true };
27332
28192
  }
@@ -27341,15 +28201,16 @@ async function clearTelegramCredentials() {
27341
28201
  return { cleared, envFallback: KEYS.filter((k) => sourceOf(k) === "env") };
27342
28202
  }
27343
28203
  async function testTelegramConnection(deps = {}) {
28204
+ const inbound = await telegramInboundReadiness(deps);
27344
28205
  const botToken = deps.botToken ?? effectiveStr("HANOMAN_TELEGRAM_BOT_TOKEN");
27345
- if (!botToken) return { ok: false, error: "Bot token belum diisi." };
28206
+ if (!botToken) return { ok: false, error: "Bot token belum diisi.", inbound };
27346
28207
  const chatId = deps.chatId ?? resolveTestChatId();
27347
28208
  if (!chatId) {
27348
- return { ok: false, error: "Isi Chat / Channel ID target \u2014 atau isi allowlist dengan tepat satu user id." };
28209
+ return { ok: false, inbound, error: "Isi Chat / Channel ID target \u2014 atau isi allowlist dengan tepat satu user id." };
27349
28210
  }
27350
28211
  const timeoutMs = deps.timeoutMs ?? 1e4;
27351
28212
  const controller = new AbortController();
27352
- const timer6 = setTimeout(() => controller.abort(), timeoutMs);
28213
+ const timer7 = setTimeout(() => controller.abort(), timeoutMs);
27353
28214
  const send = deps.transport ?? ((url2, init) => fetch(url2, init));
27354
28215
  const transport = (url2, init) => send(url2, { ...init, signal: controller.signal });
27355
28216
  const client = new TelegramApiClient(botToken, transport);
@@ -27357,13 +28218,13 @@ async function testTelegramConnection(deps = {}) {
27357
28218
  const me = await client.getMe();
27358
28219
  const stamp2 = (deps.now?.() ?? /* @__PURE__ */ new Date()).toISOString();
27359
28220
  await client.sendMessage({ chatId, text: `hanoman: uji koneksi Telegram berhasil (${stamp2}).` });
27360
- return { ok: true, botUsername: me.username ?? null, chatId };
28221
+ return { ok: true, botUsername: me.username ?? null, chatId, inbound };
27361
28222
  } catch (error) {
27362
28223
  const raw = error.message || "gagal menghubungi Telegram";
27363
28224
  const message = controller.signal.aborted ? `Timeout ${timeoutMs} ms \u2014 Telegram tidak menjawab.` : raw;
27364
- return { ok: false, error: sanitizeTelegramOutput(message, [botToken]).slice(0, 500) };
28225
+ return { ok: false, inbound, error: sanitizeTelegramOutput(message, [botToken]).slice(0, 500) };
27365
28226
  } finally {
27366
- clearTimeout(timer6);
28227
+ clearTimeout(timer7);
27367
28228
  }
27368
28229
  }
27369
28230
  function resolveTestChatId() {
@@ -27669,7 +28530,7 @@ async function sendOnce(o, deps = {}) {
27669
28530
  nowSec: o.nowSec ?? Math.floor(Date.now() / 1e3)
27670
28531
  });
27671
28532
  const ac = new AbortController();
27672
- const timer6 = setTimeout(() => ac.abort(), WEBHOOK_TIMEOUT_MS);
28533
+ const timer7 = setTimeout(() => ac.abort(), WEBHOOK_TIMEOUT_MS);
27673
28534
  try {
27674
28535
  const f = deps.fetcher ?? ((u, init) => fetch(u, init));
27675
28536
  const res = await f(parsed.url.toString(), { method: "POST", headers, body: o.body, signal: ac.signal });
@@ -27683,7 +28544,7 @@ async function sendOnce(o, deps = {}) {
27683
28544
  const msg = e.name === "AbortError" ? `timeout ${WEBHOOK_TIMEOUT_MS} ms` : e.message;
27684
28545
  return fail2(msg);
27685
28546
  } finally {
27686
- clearTimeout(timer6);
28547
+ clearTimeout(timer7);
27687
28548
  }
27688
28549
  }
27689
28550
 
@@ -28266,9 +29127,15 @@ var PUBLIC = /* @__PURE__ */ new Set([
28266
29127
  "GET /api/health",
28267
29128
  "GET /api/auth/status",
28268
29129
  "POST /api/auth/login",
28269
- "POST /api/auth/setup"
29130
+ "POST /api/auth/setup",
29131
+ // SPEC-489 · panduan AI agent. Sengaja tanpa auth: byte-nya sudah publik di GitHub, dan
29132
+ // "cukup diberi link + token" hanya benar bila link-nya terbaca SEBELUM token disetel —
29133
+ // menggerbanginya berarti agen yang capability-nya kurang menerima 403 pada dokumen yang
29134
+ // justru menjelaskan arti 403 itu.
29135
+ "GET /api/agent-integration.md"
28270
29136
  ]);
28271
- function buildApp({ requireAuth = true } = {}) {
29137
+ function buildApp({ requireAuth = true, agentDocFile } = {}) {
29138
+ const docFile = agentDocFile !== void 0 ? agentDocFile : pickGuideFile(dirname13(fileURLToPath4(import.meta.url)), process.env, existsSync12);
28272
29139
  const app2 = Fastify({ logger: false, trustProxy: true });
28273
29140
  app2.addContentTypeParser("application/json", { parseAs: "string" }, (_req, body, done) => {
28274
29141
  if (!body) return done(null, void 0);
@@ -28322,6 +29189,7 @@ function buildApp({ requireAuth = true } = {}) {
28322
29189
  api.addHook("onResponse", auditTelegramGatewayResponse);
28323
29190
  await api.register(auth_default);
28324
29191
  await api.register(health_default);
29192
+ await api.register(agent_doc_default, { file: docFile });
28325
29193
  await api.register(projects_default);
28326
29194
  await api.register(specs_default);
28327
29195
  await api.register(notifications_default);
@@ -28885,6 +29753,120 @@ function startWebhookEngine() {
28885
29753
  }).catch((e) => console.error("webhook reset:", e));
28886
29754
  }
28887
29755
 
29756
+ // src/services/auto-merge.ts
29757
+ init_src();
29758
+ init_db();
29759
+ import { execFile as execFile11 } from "node:child_process";
29760
+ import { promisify as promisify10 } from "node:util";
29761
+ var AUTO_MERGE_WINDOW_MS = 24 * 60 * 60 * 1e3;
29762
+ var AUTO_MERGE_GRACE_MS = 15 * 60 * 1e3;
29763
+ var TICK_MS4 = 6e4;
29764
+ var exec8 = promisify10(execFile11);
29765
+ var GIT7 = { timeout: 3e4, maxBuffer: 1 << 24, encoding: "utf8" };
29766
+ var gitOut = async (cwd, args) => {
29767
+ try {
29768
+ return (await exec8("git", args, { cwd, ...GIT7 })).stdout.trim();
29769
+ } catch {
29770
+ return null;
29771
+ }
29772
+ };
29773
+ var prodAutoMergeDeps = {
29774
+ repoDir: resolveRepoDir,
29775
+ defaultBranch,
29776
+ // Tanpa `fetch`: `git push` dari worktree repo yang sama memperbarui remote-tracking ref
29777
+ // di repo itu juga, jadi origin/<branch> sudah mutakhir tepat setelah sesi mem-push.
29778
+ sourceTip: async (repoDir, branch) => await gitOut(repoDir, ["rev-parse", "--verify", "-q", "--end-of-options", `refs/remotes/origin/${branch}^{commit}`]) ?? await gitOut(repoDir, ["rev-parse", "--verify", "-q", "--end-of-options", `refs/heads/${branch}^{commit}`]),
29779
+ // `merge-base --is-ancestor` keluar 0/1 tanpa output → "" saat benar, null saat salah/galat.
29780
+ contains: async (repoDir, tip, sha) => await gitOut(repoDir, ["merge-base", "--is-ancestor", "--end-of-options", sha, tip]) !== null,
29781
+ integrate,
29782
+ discardWorktree: discardMergeWorktree,
29783
+ deleteBranch: deleteMergedBranch
29784
+ };
29785
+ async function candidates(now) {
29786
+ const since = new Date(now.getTime() - AUTO_MERGE_WINDOW_MS);
29787
+ const done = await prisma.notification.findMany({
29788
+ where: { type: "done", createdAt: { gte: since }, specId: { not: null } },
29789
+ select: { specId: true, projectId: true, createdAt: true }
29790
+ });
29791
+ if (!done.length) return [];
29792
+ const keys = done.map((d) => `automerge:${d.specId}`);
29793
+ const marked2 = new Set(
29794
+ (await prisma.notification.findMany({ where: { key: { in: keys } }, select: { key: true } })).map((n2) => n2.key)
29795
+ );
29796
+ return done.filter((d) => !marked2.has(`automerge:${d.specId}`)).map((d) => ({ specId: d.specId, projectId: d.projectId ?? "", doneAt: d.createdAt }));
29797
+ }
29798
+ async function sweepAutoMerge(deps = prodAutoMergeDeps, now = /* @__PURE__ */ new Date()) {
29799
+ const list2 = await candidates(now);
29800
+ if (!list2.length) return 0;
29801
+ let settled = 0;
29802
+ for (const c of list2) {
29803
+ try {
29804
+ if (await settleOne(c, deps, now)) settled++;
29805
+ } catch (e) {
29806
+ console.error(`auto-merge ${c.specId}:`, e);
29807
+ }
29808
+ }
29809
+ return settled;
29810
+ }
29811
+ async function settleOne(c, deps, now) {
29812
+ const spec = await prisma.spec.findUnique({ where: { id: c.specId } });
29813
+ if (!spec || spec.stage !== "done") return false;
29814
+ const project = await prisma.project.findUnique({ where: { id: spec.projectId } });
29815
+ if (!project) return false;
29816
+ const policy = resolveAutoMerge(
29817
+ project.autoMerge,
29818
+ spec.autoMerge
29819
+ );
29820
+ if (policy.mode === "off") return false;
29821
+ const report = (t) => recordAutoMerge(spec.id, spec.projectId, t).then(() => true);
29822
+ const repoDir = await deps.repoDir(spec.projectId);
29823
+ if (!repoDir)
29824
+ return report(`Auto-merge ${spec.id} dilewati \u2014 project belum di-bind ke checkout lokal`);
29825
+ const target2 = autoMergeTargetOf(
29826
+ policy,
29827
+ policy.mode === "default-branch" ? await deps.defaultBranch(repoDir) : null
29828
+ );
29829
+ if (!target2)
29830
+ return report(`Auto-merge ${spec.id} dilewati \u2014 default branch repo tak bisa diresolve; pilih branch tujuan di Settings project`);
29831
+ const branch = sourceBranch(spec.id);
29832
+ const tip = await deps.sourceTip(repoDir, branch);
29833
+ const ready = tip !== null && (spec.headSha === null || await deps.contains(repoDir, tip, spec.headSha));
29834
+ if (!ready) {
29835
+ if (now.getTime() - c.doneAt.getTime() <= AUTO_MERGE_GRACE_MS) return false;
29836
+ return report(`Auto-merge ${spec.id} dilewati \u2014 branch kerja \`${branch}\` belum ter-push ke origin`);
29837
+ }
29838
+ const res = await deps.integrate(repoDir, spec.id, "merge", target2);
29839
+ if (res.status === "clean") {
29840
+ if (policy.deleteBranch) await deps.deleteBranch(repoDir, branch).catch(() => {
29841
+ });
29842
+ return report(`Auto-merge ${spec.id} \u2192 ${target2} bersih (${res.detail})`);
29843
+ }
29844
+ if (res.status === "conflict") {
29845
+ await deps.discardWorktree(repoDir, res.worktree).catch(() => {
29846
+ });
29847
+ return report(`Auto-merge ${spec.id} \u2192 ${target2} GAGAL: konflik \u2014 branch kerja \`${branch}\` utuh, selesaikan lewat Rebase / Merge di backlog`);
29848
+ }
29849
+ return report(`Auto-merge ${spec.id} \u2192 ${target2} GAGAL: ${res.error}`);
29850
+ }
29851
+ var timer6;
29852
+ var busy3 = false;
29853
+ async function tick5() {
29854
+ if (busy3) return;
29855
+ busy3 = true;
29856
+ try {
29857
+ await sweepAutoMerge();
29858
+ } catch (e) {
29859
+ console.error("auto-merge sweep:", e);
29860
+ } finally {
29861
+ busy3 = false;
29862
+ }
29863
+ }
29864
+ function startAutoMerge() {
29865
+ if (timer6) return;
29866
+ timer6 = setInterval(() => void tick5(), TICK_MS4);
29867
+ timer6.unref();
29868
+ }
29869
+
28888
29870
  // src/server.ts
28889
29871
  var app = buildApp();
28890
29872
  var port = Number(process.env.PORT ?? 8787);
@@ -28931,6 +29913,7 @@ app.listen({ port, host }).then(async () => {
28931
29913
  startScheduler();
28932
29914
  startLead();
28933
29915
  startWebhookEngine();
29916
+ startAutoMerge();
28934
29917
  }).catch((err) => {
28935
29918
  console.error("listen gagal:", err);
28936
29919
  process.exit(1);