hanoman 0.1.12 → 0.1.13

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(),
@@ -5467,8 +5659,15 @@ var init_api = __esm({
5467
5659
  leadDecisions: `${API}/lead/decisions`,
5468
5660
  leadDecisionOverride: (id) => `${API}/lead/decisions/${encodeURIComponent(id)}/override`,
5469
5661
  leadDecisionCancel: (id) => `${API}/lead/decisions/${encodeURIComponent(id)}/cancel`,
5662
+ // SPEC-485 · ADR-0102 · rantai keputusan. Di bawah prefix `lead` supaya capability-nya turunan
5663
+ // method (lead:read/lead:write) tanpa peta baru — kelas bug SPEC-405 sudah ditutup di sana.
5664
+ leadFlows: `${API}/lead/flows`,
5665
+ leadFlowSubmit: (id) => `${API}/lead/flows/${encodeURIComponent(id)}/submit`,
5666
+ leadFlowCancel: (id) => `${API}/lead/flows/${encodeURIComponent(id)}/cancel`,
5470
5667
  // SPEC-450 · ADR-0094 · katalog custom agent. `?projectId=` → himpunan EFEKTIF (global+project).
5471
5668
  customAgents: `${API}/custom-agents`,
5669
+ // SPEC-484 · ADR-0101 · sumber daftar tools/model/runtime untuk form (mention dari `customAgents`).
5670
+ customAgentCatalog: `${API}/custom-agents/catalog`,
5472
5671
  // SPEC-481 · ADR-0100 · webhook keluar (cookie-only)
5473
5672
  webhooks: `${API}/webhooks`,
5474
5673
  webhook: (id) => `${API}/webhooks/${encodeURIComponent(id)}`,
@@ -5985,6 +6184,7 @@ var init_webhook = __esm({
5985
6184
  "baseSha",
5986
6185
  "headSha",
5987
6186
  "dependsOn",
6187
+ "autoMerge",
5988
6188
  "createdAt",
5989
6189
  "startedAt",
5990
6190
  "updatedAt"
@@ -6014,6 +6214,7 @@ var init_webhook = __esm({
6014
6214
  baseSha: "5117298c5a3e63af76cbadaa46e2edfa50921d7",
6015
6215
  headSha: null,
6016
6216
  dependsOn: null,
6217
+ autoMerge: null,
6017
6218
  createdAt: "2026-08-01T02:10:00.000Z",
6018
6219
  startedAt: "2026-08-01T02:12:31.000Z",
6019
6220
  updatedAt: "2026-08-01T09:41:22.000Z"
@@ -6033,6 +6234,7 @@ var init_webhook = __esm({
6033
6234
  "helpEnabled",
6034
6235
  "schedulerOptIn",
6035
6236
  "leadOptIn",
6237
+ "autoMerge",
6036
6238
  "createdAt",
6037
6239
  "updatedAt"
6038
6240
  ],
@@ -6053,6 +6255,7 @@ var init_webhook = __esm({
6053
6255
  helpEnabled: false,
6054
6256
  schedulerOptIn: true,
6055
6257
  leadOptIn: false,
6258
+ autoMerge: null,
6056
6259
  createdAt: "2026-05-02T04:00:00.000Z",
6057
6260
  updatedAt: "2026-08-01T09:00:00.000Z"
6058
6261
  }
@@ -6819,7 +7022,7 @@ var init_mcp_catalog = __esm({
6819
7022
  {
6820
7023
  name: "hanoman_lead_ask",
6821
7024
  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.",
7025
+ 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
7026
  inputSchema: obj({
6824
7027
  properties: {
6825
7028
  project: str("Id proyek."),
@@ -6827,7 +7030,10 @@ var init_mcp_catalog = __esm({
6827
7030
  spec: str("Opsional. Id backlog yang bersangkutan."),
6828
7031
  session: str("Opsional. Id sesi yang bersangkutan."),
6829
7032
  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.")
7033
+ context: str("Opsional. Konteks pendukung, maksimum 20.000 karakter."),
7034
+ multi: bool("Opsional. `true` bila opsinya TIDAK saling eksklusif dan lead boleh memilih beberapa sekaligus. Menuntut `options` terisi."),
7035
+ minChoices: int("Opsional, hanya untuk `multi`. Paling sedikit berapa opsi harus dipilih."),
7036
+ maxChoices: int("Opsional, hanya untuk `multi`. Paling banyak berapa opsi boleh dipilih; tanpa ini sebanyak opsinya.")
6831
7037
  },
6832
7038
  required: ["project", "question"]
6833
7039
  }),
@@ -6844,7 +7050,10 @@ var init_mcp_catalog = __esm({
6844
7050
  ...s(a.spec) ? { specId: a.spec } : {},
6845
7051
  ...s(a.session) ? { sessionId: a.session } : {},
6846
7052
  ...Array.isArray(a.options) ? { options: a.options } : {},
6847
- ...s(a.context) ? { context: a.context } : {}
7053
+ ...s(a.context) ? { context: a.context } : {},
7054
+ // SPEC-485 · ADITIF: tanpa `multi` bentuk permintaannya identik dengan sebelum ADR-0102,
7055
+ // jadi klien MCP lama tak berubah perilakunya satu bit pun.
7056
+ ...a.multi === true ? { select: { mode: "multi", min: n(a.minChoices) ?? 0, max: n(a.maxChoices) ?? null } } : {}
6848
7057
  }
6849
7058
  }),
6850
7059
  shape: (raw) => raw
@@ -6883,6 +7092,7 @@ var init_src = __esm({
6883
7092
  init_entities();
6884
7093
  init_agent();
6885
7094
  init_custom_agent();
7095
+ init_agent_catalog();
6886
7096
  init_github();
6887
7097
  init_lead();
6888
7098
  init_dto();
@@ -6896,6 +7106,7 @@ var init_src = __esm({
6896
7106
  init_telegram();
6897
7107
  init_webhook();
6898
7108
  init_mcp();
7109
+ init_auto_merge();
6899
7110
  }
6900
7111
  });
6901
7112
 
@@ -7726,9 +7937,9 @@ var init_custom_agents = __esm({
7726
7937
  // ../runner/src/paths.ts
7727
7938
  import { homedir } from "node:os";
7728
7939
  import { dirname as dirname2, isAbsolute as isAbsolute2, join, resolve as resolve4 } from "node:path";
7729
- function resolveHome(env = process.env, home = homedir()) {
7940
+ function resolveHome(env = process.env, home2 = homedir()) {
7730
7941
  const v = env.HANOMAN_HOME?.trim();
7731
- return v ? v : join(home, ".hanoman");
7942
+ return v ? v : join(home2, ".hanoman");
7732
7943
  }
7733
7944
  function schemeOf(raw) {
7734
7945
  return raw.split("://")[0] ?? "?";
@@ -8145,18 +8356,38 @@ function readChoiceDialog(paneText2) {
8145
8356
  expected -= 1;
8146
8357
  }
8147
8358
  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
- }));
8359
+ const rows = run2.map((r) => {
8360
+ const m = CHECK.exec(r.label);
8361
+ const label = m ? (m[2] ?? "").trim() : r.label;
8362
+ return {
8363
+ n: r.n,
8364
+ label,
8365
+ checked: m ? m[1] !== " " : null,
8366
+ free: PLACEHOLDER.test(label),
8367
+ chat: CHAT_ROW.test(label)
8368
+ };
8369
+ });
8370
+ let submit = { present: false, focused: false };
8371
+ for (const line of lines2.slice(0, footer)) {
8372
+ const m = SUBMIT_BTN.exec(line);
8373
+ if (m) submit = { present: true, focused: !!m[1] };
8374
+ }
8154
8375
  return {
8155
8376
  rows,
8156
8377
  freeIndex: rows.find((r) => r.free)?.n ?? null,
8157
- options: rows.filter((r) => !r.free && !r.chat).map((r) => r.label)
8378
+ options: rows.filter((r) => !r.free && !r.chat).map((r) => r.label),
8379
+ multi: rows.some((r) => r.checked !== null),
8380
+ submit
8158
8381
  };
8159
8382
  }
8383
+ function focusedRow(paneText2) {
8384
+ const lines2 = paneText2.split("\n").map((l) => l.trimEnd());
8385
+ for (let i = lines2.length - 1; i >= 0; i--) {
8386
+ const m = /^\s*[❯>›]\s*(\d{1,2})\.\s+\S/.exec(lines2[i] ?? "");
8387
+ if (m) return Number(m[1]);
8388
+ }
8389
+ return null;
8390
+ }
8160
8391
  function readTabs(lines2) {
8161
8392
  for (let i = lines2.length - 1; i >= 0; i--) {
8162
8393
  const line = lines2[i] ?? "";
@@ -8206,15 +8437,17 @@ function readDialogScreen(paneText2) {
8206
8437
  options: d.options,
8207
8438
  notes: d.freeIndex === null && NOTES_FOOTER.test(lines2[footer] ?? ""),
8208
8439
  tabs,
8209
- title: readTitle(lines2, at, footer)
8440
+ title: readTitle(lines2, at, footer),
8441
+ multi: d.multi,
8442
+ submit: d.submit
8210
8443
  };
8211
8444
  }
8212
8445
  function dialogKey(paneText2) {
8213
8446
  const s2 = readDialogScreen(paneText2);
8214
8447
  if (!s2) return "none";
8215
8448
  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("|")}`;
8449
+ const tabs = s2.multi ? s2.tabs.map((t) => t.header).join(",") : s2.tabs.map((t) => `${t.answered ? "x" : "o"}${t.header}`).join(",");
8450
+ return `q|${s2.multi ? "multi|" : ""}${tabs}|${s2.title || s2.options.join("|")}`;
8218
8451
  }
8219
8452
  function freeTextFilled(paneText2, n2) {
8220
8453
  const row = readChoiceDialog(paneText2)?.rows.find((r) => r.n === n2);
@@ -8233,6 +8466,42 @@ async function answerChoiceDialog(io, freeIndex, line, chunkMs) {
8233
8466
  io.enter();
8234
8467
  return true;
8235
8468
  }
8469
+ async function answerMultiSelectDialog(io, plan, chunkMs) {
8470
+ const want = new Set(plan.pick);
8471
+ const state = () => readChoiceDialog(io.capture());
8472
+ for (const row of state()?.rows ?? []) {
8473
+ if (row.checked === null || row.free || row.chat) continue;
8474
+ if (row.checked === want.has(row.n)) continue;
8475
+ io.literal(String(row.n));
8476
+ await io.sleep(DIALOG_SETTLE_MS);
8477
+ const after = state()?.rows.find((r) => r.n === row.n);
8478
+ if (!after || after.checked !== want.has(row.n)) return false;
8479
+ }
8480
+ if (plan.line && plan.freeIndex !== null) {
8481
+ let hop2 = 0;
8482
+ while (focusedRow(io.capture()) !== plan.freeIndex && hop2 < NAV_TRIES) {
8483
+ io.down();
8484
+ await io.sleep(DIALOG_SETTLE_MS);
8485
+ hop2++;
8486
+ }
8487
+ if (focusedRow(io.capture()) !== plan.freeIndex) return false;
8488
+ for (const chunk of goalChunks(plan.line)) {
8489
+ io.literal(chunk);
8490
+ await io.sleep(chunkMs);
8491
+ }
8492
+ await io.sleep(DIALOG_SETTLE_MS);
8493
+ if (!freeTextFilled(io.capture(), plan.freeIndex)) return false;
8494
+ }
8495
+ let hop = 0;
8496
+ while (!state()?.submit.focused && hop < NAV_TRIES) {
8497
+ io.down();
8498
+ await io.sleep(DIALOG_SETTLE_MS);
8499
+ hop++;
8500
+ }
8501
+ if (!state()?.submit.focused) return false;
8502
+ io.enter();
8503
+ return true;
8504
+ }
8236
8505
  function notesFilled(paneText2) {
8237
8506
  const lines2 = paneText2.split("\n").map((l) => l.trimEnd());
8238
8507
  for (let i = lines2.length - 1; i >= 0; i--) {
@@ -8263,7 +8532,7 @@ async function submitReview(io, submitRow) {
8263
8532
  }
8264
8533
  return false;
8265
8534
  }
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;
8535
+ 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
8536
  var init_tui_dialog = __esm({
8268
8537
  "src/services/tui-dialog.ts"() {
8269
8538
  "use strict";
@@ -8274,6 +8543,8 @@ var init_tui_dialog = __esm({
8274
8543
  CHAT_ROW = /^chat about this$/i;
8275
8544
  SIDE_PANEL = /\s{2,}[│┃┌┐└┘├┤╭╮╰╯─━].*$/;
8276
8545
  cleanLabel = (s2) => s2.replace(SIDE_PANEL, "").trim();
8546
+ CHECK = /^\[([ xX✔✓])\]\s*(.*)$/;
8547
+ SUBMIT_BTN = /^\s*([❯>›])?\s{2,}(Submit|Next)\s*$/;
8277
8548
  REVIEW_PROMPT = /^\s*ready to submit your answers\?\s*$/i;
8278
8549
  SUBMIT_ROW = /^\s*[❯>›]?\s*(\d{1,2})\.\s+submit answers\s*$/i;
8279
8550
  NOTES_FOOTER = /\bn to add notes\b/i;
@@ -8284,6 +8555,7 @@ var init_tui_dialog = __esm({
8284
8555
  };
8285
8556
  RULE = /^[\s─━╌╍_=]*$/;
8286
8557
  DIALOG_SETTLE_MS = 250;
8558
+ NAV_TRIES = 24;
8287
8559
  NOTES_PLACEHOLDER = /^(?:press n to add notes|add notes on this design(?:…|\.\.\.)?)$/i;
8288
8560
  NOTES_LINE = /(?:^|\s)Notes:\s*(.*)$/;
8289
8561
  SUBMIT_TRIES = 8;
@@ -8520,7 +8792,7 @@ function createSession(projectId, cwd, opts = {}) {
8520
8792
  if (existing && !existing.exited) return existing;
8521
8793
  if (existing) killSession(id);
8522
8794
  const agentForDefs = opts.agent ?? "claude";
8523
- const customDefs = opts.command ? [] : customAgentsFor(projectId);
8795
+ const customDefs = opts.command ? [] : customAgentsFor(projectId, agentForDefs);
8524
8796
  const rosterBlock = agentForDefs === "codex" ? agentRosterBlock(customDefs) : "";
8525
8797
  let promptArg = "";
8526
8798
  if (!opts.command && opts.prompt) {
@@ -8665,7 +8937,7 @@ function capturePane(id, lines2 = 200) {
8665
8937
  return "";
8666
8938
  }
8667
8939
  }
8668
- async function sendToPane(id, text, chunkMs = 50) {
8940
+ async function sendToPane(id, text, chunkMs = 50, choices = []) {
8669
8941
  const p = getSession(id);
8670
8942
  if (!p || p.exited) return false;
8671
8943
  const line = text.replace(/\s*\r?\n\s*/g, " ").trim();
@@ -8675,6 +8947,10 @@ async function sendToPane(id, text, chunkMs = 50) {
8675
8947
  const screen = readDialogScreen(io.capture());
8676
8948
  if (screen?.kind === "review") return await submitReview(io, screen.submitRow);
8677
8949
  if (screen?.kind === "question") {
8950
+ if (screen.multi && screen.submit.present) {
8951
+ const pick2 = resolveChoices(choices, screen.options).choices.map((c) => c.index);
8952
+ return await answerMultiSelectDialog(io, { pick: pick2, line, freeIndex: screen.freeIndex }, chunkMs);
8953
+ }
8678
8954
  if (screen.freeIndex !== null) return await answerChoiceDialog(io, screen.freeIndex, line, chunkMs);
8679
8955
  if (screen.notes) return await answerNotesDialog(io, line, chunkMs);
8680
8956
  }
@@ -8969,9 +9245,9 @@ var init_pty = __esm({
8969
9245
  }
8970
9246
  };
8971
9247
  customAgentSource = () => [];
8972
- customAgentsFor = (projectId) => {
9248
+ customAgentsFor = (projectId, agent) => {
8973
9249
  try {
8974
- return customAgentSource(projectId);
9250
+ return customAgentSource(projectId, agent);
8975
9251
  } catch {
8976
9252
  return [];
8977
9253
  }
@@ -8994,6 +9270,11 @@ var init_pty = __esm({
8994
9270
  enter: () => {
8995
9271
  tmux("send-keys", "-t", name(id), "Enter");
8996
9272
  },
9273
+ // SPEC-485 · SATU panah per pemanggilan: terukur, empat `Down` dalam satu `send-keys` memindahkan
9274
+ // fokus satu baris saja (jebakan burst ADR-0085, kali ini pada tombol kendali).
9275
+ down: () => {
9276
+ tmux("send-keys", "-t", name(id), "Down");
9277
+ },
8997
9278
  sleep
8998
9279
  });
8999
9280
  DIALOG_CAPTURE_LINES = 60;
@@ -9273,7 +9554,10 @@ var init_sync = __esm({
9273
9554
  // ada: `upsert` yang tak menyebut kolom ber-default TETAP berhasil, jadi kolom yang terlewat
9274
9555
  // mendarat sebagai default palsu di tiap client tanpa satu pun error (kelas ADR-0090/0093).
9275
9556
  // `version` tak pernah masuk FIELDS — ia stempel mekanisme sync itu sendiri.
9276
- customAgent: ["projectId", "name", "description", "instructions", "tools", "model", "mentions", "enabled", "createdAt", "updatedAt"],
9557
+ // SPEC-484 · ADR-0101 · `runtime` ikut: ia menentukan sesi mesin mana yang memakai persona ini,
9558
+ // dan kolom yang terlewat di sini mendarat sebagai default palsu (= "warisi") di setiap mesin
9559
+ // lain tanpa satu pun error.
9560
+ customAgent: ["projectId", "name", "description", "instructions", "tools", "model", "mentions", "runtime", "enabled", "createdAt", "updatedAt"],
9277
9561
  // SPEC-471 · ADR-0095 · SELURUH kolom bermakna ikut. `status`/`specId` termasuk: keputusan
9278
9562
  // triase adalah bagian keadaan yang harus dilihat sama oleh semua mesin — tanpa itu satu
9279
9563
  // mesin bisa menerima ulang issue yang di mesin lain sudah jadi backlog.
@@ -9387,8 +9671,8 @@ var init_settings2 = __esm({
9387
9671
  // src/services/codex-trust.ts
9388
9672
  import { appendFileSync, mkdirSync as mkdirSync6, readFileSync as readFileSync5, realpathSync as realpathSync2 } from "node:fs";
9389
9673
  import { homedir as homedir2 } from "node:os";
9390
- function ensureCodexTrust(repoDir, home) {
9391
- const path = codexConfigPath(home);
9674
+ function ensureCodexTrust(repoDir, home2) {
9675
+ const path = codexConfigPath(home2);
9392
9676
  let canonical = repoDir;
9393
9677
  try {
9394
9678
  canonical = realpathSync2(repoDir);
@@ -9402,7 +9686,7 @@ function ensureCodexTrust(repoDir, home) {
9402
9686
  } catch {
9403
9687
  }
9404
9688
  if (existing.includes(header2)) return;
9405
- mkdirSync6(codexHome(home), { recursive: true });
9689
+ mkdirSync6(codexHome(home2), { recursive: true });
9406
9690
  const lead = existing === "" || existing.endsWith("\n") ? "" : "\n";
9407
9691
  appendFileSync(path, `${lead}
9408
9692
  ${header2}
@@ -9415,8 +9699,8 @@ var codexHome, codexConfigPath;
9415
9699
  var init_codex_trust = __esm({
9416
9700
  "src/services/codex-trust.ts"() {
9417
9701
  "use strict";
9418
- codexHome = (home) => home ?? process.env.CODEX_HOME ?? `${homedir2()}/.codex`;
9419
- codexConfigPath = (home) => `${codexHome(home)}/config.toml`;
9702
+ codexHome = (home2) => home2 ?? process.env.CODEX_HOME ?? `${homedir2()}/.codex`;
9703
+ codexConfigPath = (home2) => `${codexHome(home2)}/config.toml`;
9420
9704
  }
9421
9705
  });
9422
9706
 
@@ -9992,12 +10276,12 @@ ${input.text}`;
9992
10276
  });
9993
10277
 
9994
10278
  // src/services/telegram/store.ts
9995
- import { Prisma } from "@prisma/client";
10279
+ import { Prisma as Prisma3 } from "@prisma/client";
9996
10280
  var isUnique, TelegramStore;
9997
10281
  var init_store = __esm({
9998
10282
  "src/services/telegram/store.ts"() {
9999
10283
  "use strict";
10000
- isUnique = (error) => error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002";
10284
+ isUnique = (error) => error instanceof Prisma3.PrismaClientKnownRequestError && error.code === "P2002";
10001
10285
  TelegramStore = class {
10002
10286
  // SPEC-481 · klien yang diekspor kini ber-extension (tap webhook) sehingga tak lagi
10003
10287
  // assignable ke `PrismaClient` polos. `Db` diturunkan dari nilai nyatanya.
@@ -14362,7 +14646,7 @@ function syncStatus() {
14362
14646
  async function startSyncClient(base2, token, tickMs2) {
14363
14647
  started = true;
14364
14648
  const transport = fetchTransport(base2, token);
14365
- const tick5 = async () => {
14649
+ const tick6 = async () => {
14366
14650
  try {
14367
14651
  await syncOnce(transport);
14368
14652
  } catch {
@@ -14373,13 +14657,13 @@ async function startSyncClient(base2, token, tickMs2) {
14373
14657
  const wsUrl = base2.replace(/^http/, "ws").replace(/\/$/, "") + `/api/sync/ws?token=${encodeURIComponent(token)}`;
14374
14658
  ws = new WebSocket2(wsUrl);
14375
14659
  ws.on("open", () => {
14376
- void tick5();
14660
+ void tick6();
14377
14661
  });
14378
14662
  ws.on("message", async (raw) => {
14379
14663
  try {
14380
14664
  const msg = JSON.parse(raw.toString());
14381
14665
  if (msg.t !== "sync") return;
14382
- if (!await applyFeedFrame(msg)) void tick5();
14666
+ if (!await applyFeedFrame(msg)) void tick6();
14383
14667
  } catch {
14384
14668
  }
14385
14669
  });
@@ -14396,11 +14680,11 @@ async function startSyncClient(base2, token, tickMs2) {
14396
14680
  }
14397
14681
  });
14398
14682
  };
14399
- await tick5();
14683
+ await tick6();
14400
14684
  void connectWs();
14401
14685
  const ms = tickMs2 && tickMs2 > 0 ? tickMs2 : 15e3;
14402
14686
  timer2 = setInterval(() => {
14403
- void tick5();
14687
+ void tick6();
14404
14688
  }, ms);
14405
14689
  timer2.unref?.();
14406
14690
  }
@@ -17819,6 +18103,7 @@ init_src();
17819
18103
 
17820
18104
  // src/services/project-view.ts
17821
18105
  init_pty();
18106
+ init_src();
17822
18107
  var IDLE = { status: "idle", phase: null, flow: null };
17823
18108
  function sessionOf(projectId, sessions) {
17824
18109
  const s2 = sessions.find((x) => x.projectId === projectId && x.specId && !x.exited);
@@ -17858,7 +18143,9 @@ async function toProjectView(p, sessions) {
17858
18143
  // SPEC-294 · opt-in scheduler otonom (lokal per-instance).
17859
18144
  schedulerOptIn: p.schedulerOptIn,
17860
18145
  // SPEC-409 · ADR-0091 · opt-in hanoman-lead (lokal per-instance, cermin schedulerOptIn).
17861
- leadOptIn: p.leadOptIn
18146
+ leadOptIn: p.leadOptIn,
18147
+ // SPEC-486 · ADR-0103 · kebijakan auto-merge (lokal per-instance, cermin schedulerOptIn).
18148
+ autoMerge: autoMergeOf(p.autoMerge)
17862
18149
  };
17863
18150
  }
17864
18151
 
@@ -17899,8 +18186,42 @@ async function branchFromCandidates(repoDir) {
17899
18186
  const [local, remote] = await Promise.all([listRepoBranches(repoDir), listRepoRemoteBranches(repoDir)]);
17900
18187
  return [.../* @__PURE__ */ new Set([...local, ...remote])].sort();
17901
18188
  }
18189
+ async function defaultBranch(repoDir) {
18190
+ if (!repoDir) return null;
18191
+ try {
18192
+ const { stdout } = await exec2(
18193
+ "git",
18194
+ ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"],
18195
+ { cwd: repoDir, ...GIT }
18196
+ );
18197
+ const name2 = stdout.trim().replace(/^origin\//, "");
18198
+ if (name2 && name2 !== "HEAD") return name2;
18199
+ } catch {
18200
+ }
18201
+ const all = /* @__PURE__ */ new Set([...await listRepoBranches(repoDir), ...await listRepoRemoteBranches(repoDir)]);
18202
+ for (const c of ["main", "master"]) if (all.has(c)) return c;
18203
+ return null;
18204
+ }
18205
+
18206
+ // src/services/auto-merge-gate.ts
18207
+ init_src();
18208
+ async function checkAutoMerge(repoDir, raw) {
18209
+ if (raw === null || raw === void 0) return { ok: true };
18210
+ const p = autoMergeOf(raw);
18211
+ if (!p) return { ok: false, code: 400, error: "bentuk kebijakan auto-merge tak sah" };
18212
+ if (p.mode === "off") return { ok: true };
18213
+ if (!repoDir)
18214
+ return { ok: false, code: 409, error: "project belum di-bind ke checkout lokal \u2014 atur repoDir dulu sebelum menyalakan auto-merge" };
18215
+ if (p.mode === "default-branch") {
18216
+ 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" };
18217
+ }
18218
+ if (!p.branch) return { ok: false, code: 400, error: 'mode "branch" butuh branch tujuan' };
18219
+ const known = p.dest === "origin" ? await listRepoRemoteBranches(repoDir) : await listRepoBranches(repoDir);
18220
+ 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` };
18221
+ }
17902
18222
 
17903
18223
  // src/routes/projects.ts
18224
+ import { Prisma } from "@prisma/client";
17904
18225
  init_pty();
17905
18226
 
17906
18227
  // src/services/paginate.ts
@@ -17963,7 +18284,13 @@ async function projects_default(app2) {
17963
18284
  const parsed = zUpdateProject.safeParse(req.body);
17964
18285
  if (!parsed.success) return reply.code(400).send({ error: parsed.error.flatten() });
17965
18286
  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 });
18287
+ if ("autoMerge" in parsed.data) {
18288
+ const gate = await checkAutoMerge(await resolveRepoDir(id), parsed.data.autoMerge);
18289
+ if (!gate.ok) return reply.code(gate.code).send({ error: gate.error });
18290
+ }
18291
+ const data = { ...parsed.data };
18292
+ if ("autoMerge" in data && data.autoMerge === null) data.autoMerge = Prisma.DbNull;
18293
+ const updated = await prisma.project.update({ where: { id }, data });
17967
18294
  await notifySynced("project", id);
17968
18295
  return toProjectView(updated, listSessions());
17969
18296
  });
@@ -17995,7 +18322,11 @@ async function projects_default(app2) {
17995
18322
  const p = await prisma.project.findUnique({ where: { id } });
17996
18323
  if (!p) return reply.code(404).send({ error: "not found" });
17997
18324
  const repoDir = await resolveRepoDir(id);
17998
- return { branches: await listRepoBranches(repoDir), remotes: await listRepoRemoteBranches(repoDir) };
18325
+ return {
18326
+ branches: await listRepoBranches(repoDir),
18327
+ remotes: await listRepoRemoteBranches(repoDir),
18328
+ defaultBranch: await defaultBranch(repoDir)
18329
+ };
17999
18330
  });
18000
18331
  app2.get("/projects/:id/help-center", async (req, reply) => {
18001
18332
  const { id } = req.params;
@@ -18121,6 +18452,9 @@ async function resolveGraphSource(repoDir, source) {
18121
18452
  if (await refExists(repoDir, cand)) return cand;
18122
18453
  return null;
18123
18454
  }
18455
+ async function discardMergeWorktree(repoDir, wt) {
18456
+ await reclaim(repoDir, wt);
18457
+ }
18124
18458
  async function deleteMergedBranch(repoDir, branch) {
18125
18459
  await sh(repoDir, ["branch", "-D", "--end-of-options", branch]);
18126
18460
  if (await refExists(repoDir, `refs/remotes/origin/${branch}`))
@@ -18246,9 +18580,9 @@ async function revOk(wt, rev) {
18246
18580
  return exec4("git", ["rev-parse", "--verify", "-q", "--end-of-options", `${rev}^{commit}`], { cwd: wt, ...GIT3 }).then(() => true).catch(() => false);
18247
18581
  }
18248
18582
  async function mergeBase(wt, baseSha, branchFrom) {
18249
- const candidates = [baseSha, branchFrom, "main", "master"].filter((c) => !!c);
18583
+ const candidates2 = [baseSha, branchFrom, "main", "master"].filter((c) => !!c);
18250
18584
  let base2 = "HEAD";
18251
- for (const c of candidates) if (await revOk(wt, c)) {
18585
+ for (const c of candidates2) if (await revOk(wt, c)) {
18252
18586
  base2 = c;
18253
18587
  break;
18254
18588
  }
@@ -18641,6 +18975,9 @@ async function validateDependsOn(specId, projectId, raw) {
18641
18975
  return { ok: true, ids };
18642
18976
  }
18643
18977
 
18978
+ // src/routes/specs.ts
18979
+ import { Prisma as Prisma2 } from "@prisma/client";
18980
+
18644
18981
  // src/services/doc-export.ts
18645
18982
  import PDFDocument from "pdfkit";
18646
18983
 
@@ -21065,6 +21402,13 @@ async function recordFailure(specId, title, projectId, reason) {
21065
21402
  }).catch(() => {
21066
21403
  });
21067
21404
  }
21405
+ async function recordAutoMerge(specId, projectId, title) {
21406
+ const sessionId2 = specId.toLowerCase().replace(/[^a-z0-9_-]/g, "_");
21407
+ await prisma.notification.create({
21408
+ data: { type: "automerge", key: `automerge:${specId}`, specId, sessionId: sessionId2, title, projectId }
21409
+ }).catch(() => {
21410
+ });
21411
+ }
21068
21412
  async function recordNewTicket(ticketId, projectId, projectName, category, title) {
21069
21413
  const short = title.length > 80 ? title.slice(0, 77) + "\u2026" : title;
21070
21414
  const t = `Keluhan baru di "${projectName}": ${category}: ${short}`;
@@ -21274,7 +21618,7 @@ ${item.context}` : item.context;
21274
21618
  if (!parsed.success) return reply.code(400).send({ error: parsed.error.flatten() });
21275
21619
  const spec = await prisma.spec.findUnique({ where: { id } });
21276
21620
  if (!spec) return reply.code(404).send({ error: "not found" });
21277
- const { branchFrom, stage, confirmDelete, title, priority: newPriority, payload, dependsOn } = parsed.data;
21621
+ const { branchFrom, stage, confirmDelete, title, priority: newPriority, payload, dependsOn, autoMerge } = parsed.data;
21278
21622
  const editingContent = title !== void 0 || newPriority !== void 0 || payload !== void 0;
21279
21623
  if (editingContent && (spec.stage !== "brainstorming" || spec.baseSha !== null))
21280
21624
  return reply.code(409).send({ error: "backlog item sudah dimulai \u2014 tak bisa diedit" });
@@ -21288,6 +21632,10 @@ ${item.context}` : item.context;
21288
21632
  if (!d.ok) return reply.code(400).send({ error: d.error });
21289
21633
  depIds = d.ids;
21290
21634
  }
21635
+ if ("autoMerge" in parsed.data) {
21636
+ const gate = await checkAutoMerge(await resolveRepoDir(spec.projectId), autoMerge);
21637
+ if (!gate.ok) return reply.code(gate.code).send({ error: gate.error });
21638
+ }
21291
21639
  if (stage !== void 0) {
21292
21640
  if (STAGES.indexOf(stage) >= STAGES.indexOf(spec.stage))
21293
21641
  return reply.code(422).send({ error: "stage hanya boleh dikembalikan mundur" });
@@ -21300,6 +21648,7 @@ ${item.context}` : item.context;
21300
21648
  const data = {};
21301
21649
  if (branchFrom !== void 0) data.branchFrom = branchFrom;
21302
21650
  if (depIds !== void 0) data.dependsOn = depIds;
21651
+ if ("autoMerge" in parsed.data) data.autoMerge = autoMerge === null ? Prisma2.DbNull : autoMerge;
21303
21652
  if (stage !== void 0) data.stage = stage;
21304
21653
  if (editingContent) {
21305
21654
  const effPayload = payload ?? spec.payload;
@@ -23202,9 +23551,9 @@ function sshExec(t, remoteCmd, opts = {}) {
23202
23551
  return new Promise((resolve14) => {
23203
23552
  const p = spawn3(sshBin(), args, { stdio: ["pipe", "pipe", "pipe"], env });
23204
23553
  let out3 = "";
23205
- const timer6 = setTimeout(() => p.kill("SIGKILL"), opts.timeoutMs ?? 6e4);
23554
+ const timer7 = setTimeout(() => p.kill("SIGKILL"), opts.timeoutMs ?? 6e4);
23206
23555
  const done = (r) => {
23207
- clearTimeout(timer6);
23556
+ clearTimeout(timer7);
23208
23557
  if (askpass) rmSync4(dirname10(askpass), { recursive: true, force: true });
23209
23558
  resolve14(r);
23210
23559
  };
@@ -25322,6 +25671,16 @@ function leadPrompt(q, c) {
25322
25671
  }
25323
25672
  lines2.push("");
25324
25673
  }
25674
+ if (c.chainSteps?.length) {
25675
+ lines2.push("## Rantai keputusan ini (langkah yang sudah dijawab)");
25676
+ for (const [i, s2] of c.chainSteps.entries()) {
25677
+ lines2.push(`${i + 1}. "${s2.question.slice(0, 200)}" \u2192 ${s2.picked.length ? s2.picked.join("; ") : "(tanpa pilihan)"}`);
25678
+ if (s2.options.length) lines2.push(` opsi saat itu: ${s2.options.map((o) => o.slice(0, 80)).join(" \xB7 ")}`);
25679
+ }
25680
+ lines2.push("");
25681
+ lines2.push("Pertanyaan di bawah adalah lanjutan dari rantai itu. Jangan bertentangan dengan yang sudah kamu putuskan di atas, dan jangan mengulang penjelasannya.");
25682
+ lines2.push("");
25683
+ }
25325
25684
  lines2.push("## Yang harus kamu putuskan");
25326
25685
  lines2.push(q.question.trim());
25327
25686
  if (q.options?.length) {
@@ -25329,7 +25688,12 @@ function leadPrompt(q, c) {
25329
25688
  lines2.push("Opsi yang dilihat peminta:");
25330
25689
  for (const [i, o] of q.options.entries()) lines2.push(`${i + 1}. ${o}`);
25331
25690
  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.');
25691
+ if (c.select?.mode === "multi") {
25692
+ 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.`);
25693
+ } else {
25694
+ lines2.push('Salah satu dari daftar itu WAJIB kamu pilih lewat field `choice` \u2014 isi nomornya ("2") atau labelnya persis.');
25695
+ }
25696
+ lines2.push("Pilihan di luar daftar ditolak server, dicatat sebagai penolakan, dan peminta kembali menunggu manusia.");
25333
25697
  }
25334
25698
  lines2.push("");
25335
25699
  lines2.push("## Batas waktu (BACA INI DULU)");
@@ -25356,6 +25720,7 @@ function leadPrompt(q, c) {
25356
25720
  lines2.push(JSON.stringify({
25357
25721
  decision: "keputusan yang dipilih, satu kalimat",
25358
25722
  choice: "nomor atau label opsi yang kamu pilih; kosongkan bila tak ada daftar opsi",
25723
+ choices: [],
25359
25724
  reason: "alasannya, dua-tiga kalimat, menyebut bukti",
25360
25725
  refs: ["internal/docs/\u2026", "ADR-00xx"],
25361
25726
  confidence: "tinggi | sedang | ragu",
@@ -25414,6 +25779,7 @@ function keepExistingRefs(refs2, repoDir) {
25414
25779
 
25415
25780
  // src/services/lead/trail.ts
25416
25781
  init_db();
25782
+ init_src();
25417
25783
  async function recordDecision(i) {
25418
25784
  return prisma.leadDecision.create({
25419
25785
  data: {
@@ -25436,6 +25802,11 @@ async function recordDecision(i) {
25436
25802
  // null eksplisit, sementara "tak disebut" pada create sudah berarti NULL di baris barunya.
25437
25803
  options: i.options?.length ? i.options : void 0,
25438
25804
  missing: i.missing?.length ? i.missing : void 0,
25805
+ // SPEC-485 · daftar kosong disimpan NULL dengan alasan yang sama seperti `options` di atas.
25806
+ choices: i.choices?.length ? i.choices : void 0,
25807
+ select: i.select ?? void 0,
25808
+ flowId: i.flowId ?? null,
25809
+ step: i.step ?? null,
25439
25810
  status: i.status ?? "berlaku",
25440
25811
  weighty: i.weighty ?? false,
25441
25812
  actor: i.actor ?? "lead"
@@ -25448,16 +25819,21 @@ async function listDecisions(f = {}) {
25448
25819
  ...f.projectId ? { projectId: f.projectId } : {},
25449
25820
  ...f.specId ? { specId: f.specId } : {},
25450
25821
  ...f.sessionId ? { sessionId: f.sessionId } : {},
25822
+ ...f.flowId ? { flowId: f.flowId } : {},
25451
25823
  ...f.status ? { status: f.status } : {}
25452
25824
  },
25453
- orderBy: { createdAt: "desc" },
25825
+ // SPEC-485 · satu RANTAI dibaca dari awal: urutan pertanyaannya adalah isi jejaknya. Daftar
25826
+ // umum tetap terbaru-dulu (AC-24) — dua pertanyaan yang berbeda, dua urutan yang berbeda.
25827
+ orderBy: { createdAt: f.flowId ? "asc" : "desc" },
25454
25828
  take: Math.min(f.take ?? 50, 200),
25455
25829
  skip: f.skip ?? 0
25456
25830
  });
25457
25831
  }
25458
- async function overrideDecision(id, answer, reason) {
25832
+ async function overrideDecision(id, answer, reason, rawChoices = []) {
25459
25833
  const old = await prisma.leadDecision.findUnique({ where: { id } });
25460
25834
  if (!old || old.status !== "berlaku") return null;
25835
+ const options2 = Array.isArray(old.options) ? old.options.map(String) : [];
25836
+ const { choices } = resolveChoices(rawChoices, options2);
25461
25837
  const next = await recordDecision({
25462
25838
  projectId: old.projectId,
25463
25839
  specId: old.specId,
@@ -25470,7 +25846,14 @@ async function overrideDecision(id, answer, reason) {
25470
25846
  refs: [],
25471
25847
  confidence: "tinggi",
25472
25848
  action: "none",
25473
- actor: "operator"
25849
+ actor: "operator",
25850
+ choices,
25851
+ choice: choices[0]?.option ?? null,
25852
+ choiceIndex: choices[0]?.index ?? null,
25853
+ options: options2,
25854
+ select: old.select,
25855
+ flowId: old.flowId,
25856
+ step: old.step
25474
25857
  });
25475
25858
  const updated = await prisma.leadDecision.update({
25476
25859
  where: { id },
@@ -25502,6 +25885,13 @@ function toDecisionView(r) {
25502
25885
  choiceIndex: r.choiceIndex,
25503
25886
  options: Array.isArray(r.options) ? r.options.map(String) : [],
25504
25887
  missing: Array.isArray(r.missing) ? r.missing.map(String) : [],
25888
+ // SPEC-485 · ADR-0102 · jawaban SELALU daftar di permukaan baca. Baris pra-migrasi tak punya
25889
+ // kolomnya, jadi ia DITURUNKAN dari pasangan skalar lama — itulah yang membuat riwayat lama
25890
+ // tetap terbaca sesudah perubahan skema, tanpa satu pun backfill.
25891
+ 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 }] : [],
25892
+ select: r.select ?? null,
25893
+ flowId: r.flowId,
25894
+ step: r.step,
25505
25895
  status: r.status,
25506
25896
  weighty: r.weighty,
25507
25897
  supersededById: r.supersededById,
@@ -25629,6 +26019,98 @@ async function runPool(items, limit, fn) {
25629
26019
  await Promise.all(Array.from({ length: workers }, worker));
25630
26020
  }
25631
26021
 
26022
+ // src/services/lead/flow.ts
26023
+ init_src();
26024
+ init_db();
26025
+ var LeadFlowClosedError = class extends Error {
26026
+ constructor(flowId, flowStatus) {
26027
+ super(`rantai keputusan ${flowId} sudah ${flowStatus === "tak-ada" ? "tidak ada" : flowStatus}`);
26028
+ this.flowId = flowId;
26029
+ this.flowStatus = flowStatus;
26030
+ this.name = "LeadFlowClosedError";
26031
+ }
26032
+ };
26033
+ var isOpen = (s2) => LEAD_FLOW_OPEN.includes(s2);
26034
+ var flowTitle = (q) => q.replace(/\s+/g, " ").trim().slice(0, 200);
26035
+ async function openFlow(i) {
26036
+ return prisma.leadFlow.create({
26037
+ data: {
26038
+ projectId: i.projectId,
26039
+ specId: i.specId ?? null,
26040
+ sessionId: i.sessionId ?? null,
26041
+ gate: i.gate,
26042
+ title: flowTitle(i.title),
26043
+ expiresAt: new Date(Date.now() + Math.max(1, i.ttlMin) * 6e4)
26044
+ }
26045
+ });
26046
+ }
26047
+ async function joinFlow(id) {
26048
+ const row = await prisma.leadFlow.findUnique({ where: { id } });
26049
+ if (!row) throw new LeadFlowClosedError(id, "tak-ada");
26050
+ if (!isOpen(row.status)) throw new LeadFlowClosedError(id, row.status);
26051
+ return row;
26052
+ }
26053
+ async function markFlowStep(id, answered) {
26054
+ const row = await prisma.leadFlow.findUnique({ where: { id } });
26055
+ if (!row || !isOpen(row.status)) return;
26056
+ await prisma.leadFlow.update({
26057
+ where: { id },
26058
+ data: {
26059
+ steps: { increment: 1 },
26060
+ ...answered && row.status === "menunggu" ? { status: "sebagian" } : {}
26061
+ }
26062
+ });
26063
+ }
26064
+ async function closeFlow(id, reason) {
26065
+ const row = await prisma.leadFlow.findUnique({ where: { id } });
26066
+ if (!row || !isOpen(row.status)) return null;
26067
+ const status = reason === "operator" || reason === "kedaluwarsa" ? "dibatalkan" : "selesai";
26068
+ return prisma.leadFlow.update({
26069
+ where: { id },
26070
+ data: { status, closeReason: reason, closedAt: /* @__PURE__ */ new Date() }
26071
+ });
26072
+ }
26073
+ async function listFlows(f = {}) {
26074
+ return prisma.leadFlow.findMany({
26075
+ where: {
26076
+ ...f.projectId ? { projectId: f.projectId } : {},
26077
+ ...f.status ? { status: f.status } : {}
26078
+ },
26079
+ orderBy: { createdAt: "desc" },
26080
+ take: Math.min(f.take ?? 50, 200),
26081
+ skip: f.skip ?? 0
26082
+ });
26083
+ }
26084
+ async function expireFlows(now) {
26085
+ const due = await prisma.leadFlow.findMany({
26086
+ where: { status: { in: [...LEAD_FLOW_OPEN] }, expiresAt: { lt: now } },
26087
+ orderBy: { createdAt: "asc" },
26088
+ take: 100
26089
+ });
26090
+ const out3 = [];
26091
+ for (const f of due) {
26092
+ const closed = await closeFlow(f.id, "kedaluwarsa");
26093
+ if (closed) out3.push(closed);
26094
+ }
26095
+ return out3;
26096
+ }
26097
+ function toFlowView(r) {
26098
+ return {
26099
+ id: r.id,
26100
+ projectId: r.projectId,
26101
+ specId: r.specId,
26102
+ sessionId: r.sessionId,
26103
+ gate: r.gate,
26104
+ status: r.status,
26105
+ title: r.title,
26106
+ steps: r.steps,
26107
+ closeReason: r.closeReason,
26108
+ openedAt: r.openedAt.toISOString(),
26109
+ closedAt: r.closedAt ? r.closedAt.toISOString() : null,
26110
+ expiresAt: r.expiresAt.toISOString()
26111
+ };
26112
+ }
26113
+
25632
26114
  // src/services/lead/decide.ts
25633
26115
  var prodDecideDeps = {
25634
26116
  think,
@@ -25652,6 +26134,15 @@ var notifTitle = (kind, question, answer, confidence) => {
25652
26134
  async function decide(req, deps = prodDecideDeps) {
25653
26135
  const cfg = await getLead();
25654
26136
  if (!leadActive(cfg, req.projectId)) return null;
26137
+ const flow = req.flowId ? await joinFlow(req.flowId) : await openFlow({
26138
+ projectId: req.projectId,
26139
+ specId: req.specId,
26140
+ sessionId: req.sessionId,
26141
+ gate: req.gate,
26142
+ title: req.question,
26143
+ ttlMin: cfg.flowTtlMin
26144
+ });
26145
+ const step = flow.steps + 1;
25655
26146
  const project = await prisma.project.findUnique({ where: { id: req.projectId }, select: { name: true } });
25656
26147
  const spec = req.specId ? await prisma.spec.findUnique({
25657
26148
  where: { id: req.specId },
@@ -25663,6 +26154,9 @@ async function decide(req, deps = prodDecideDeps) {
25663
26154
  orderBy: { createdAt: "desc" },
25664
26155
  take: 10
25665
26156
  });
26157
+ const chainRows = req.flowId ? await prisma.leadDecision.findMany({ where: { flowId: flow.id }, orderBy: { createdAt: "asc" }, take: 10 }) : [];
26158
+ const options2 = req.options ?? [];
26159
+ const bounds = normalizeSelect(req.select ?? { mode: "single", min: 0, max: null }, options2.length);
25666
26160
  const ctx = {
25667
26161
  projectId: req.projectId,
25668
26162
  projectName: project?.name ?? req.projectId,
@@ -25679,7 +26173,13 @@ async function decide(req, deps = prodDecideDeps) {
25679
26173
  reason: d.reason,
25680
26174
  createdAt: d.createdAt.toISOString()
25681
26175
  })),
25682
- notes: req.notes
26176
+ notes: req.notes,
26177
+ select: bounds,
26178
+ chainSteps: chainRows.map((d) => ({
26179
+ question: d.question,
26180
+ options: Array.isArray(d.options) ? d.options.map(String) : [],
26181
+ picked: Array.isArray(d.choices) ? d.choices.map((c) => String(c?.option ?? "")).filter(Boolean) : d.choice ? [d.choice] : []
26182
+ }))
25683
26183
  };
25684
26184
  const { agent, model, effort } = await deps.defaults();
25685
26185
  const prompt = leadPrompt({ kind: req.kind, question: req.question, options: req.options }, ctx);
@@ -25698,24 +26198,30 @@ async function decide(req, deps = prodDecideDeps) {
25698
26198
  }
25699
26199
  });
25700
26200
  } catch (e) {
25701
- if (e instanceof LeadBusyError) throw e;
25702
- return fail(req, deps, `lead tak menghasilkan keputusan: ${e.message}`);
26201
+ if (e instanceof LeadBusyError) {
26202
+ if (!req.flowId) await closeFlow(flow.id, "kedaluwarsa").catch(() => null);
26203
+ throw e;
26204
+ }
26205
+ return closeAfter(await fail(req, deps, `lead tak menghasilkan keputusan: ${e.message}`, flow.id, step), flow.id, req.chain);
25703
26206
  } finally {
25704
26207
  if (req.sessionId) clearQueued(req.sessionId);
25705
26208
  }
25706
26209
  const verdict = parseLeadVerdict(raw);
25707
- if (!verdict) return fail(req, deps, "keluaran lead tak memuat blok json keputusan yang sah");
26210
+ 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
26211
  const refs2 = keepExistingRefs(verdict.refs, repoDir);
25709
26212
  const allowed = leadActionAllowed(verdict.action);
25710
26213
  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;
26214
+ const rawChoices = verdict.choices.length ? verdict.choices : verdict.choice.trim() ? [verdict.choice] : [];
26215
+ const resolved = resolveChoices(rawChoices, options2);
26216
+ const countProblem = options2.length && resolved.choices.length ? checkChoiceCount(resolved.choices.length, bounds) : null;
26217
+ const choices = countProblem ? [] : resolved.choices;
26218
+ const choice = choices[0] ?? null;
26219
+ const choiceRejected = options2.length > 0 && rawChoices.length > 0 && (resolved.rejected.length > 0 || !!countProblem);
25714
26220
  const missing = verdict.missing.map((m) => m.trim()).filter(Boolean);
25715
26221
  let action = allowed ? verdict.action : "none";
25716
26222
  let actionNote = "";
25717
26223
  let conflict = false;
25718
- if (allowed && choice) {
26224
+ if (allowed && choices.length === 1 && choice) {
25719
26225
  const hint = optionActionHint(choice.option);
25720
26226
  if (hint && action === "none" && hint !== "none") {
25721
26227
  action = hint;
@@ -25729,7 +26235,10 @@ async function decide(req, deps = prodDecideDeps) {
25729
26235
  const confidence = missing.length ? "ragu" : verdict.confidence;
25730
26236
  const notes = [];
25731
26237
  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).`);
26238
+ if (options2.length && resolved.rejected.length)
26239
+ 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).`);
26240
+ if (countProblem)
26241
+ notes.push(`DITOLAK: ${countProblem} (SPEC-485 \xB7 ADR-0102) \u2014 seluruh pilihan dibatalkan, bukan dipangkas.`);
25733
26242
  if (actionNote) notes.push(actionNote);
25734
26243
  if (missing.length) notes.push(`KONTEKS KURANG: ${missing.join("; ")}`);
25735
26244
  const tail3 = notes.length ? `
@@ -25754,9 +26263,14 @@ ${notes.join("\n\n")}` : "";
25754
26263
  weighty,
25755
26264
  choice: choice?.option ?? null,
25756
26265
  choiceIndex: choice?.index ?? null,
26266
+ choices,
26267
+ select: bounds,
26268
+ flowId: flow.id,
26269
+ step,
25757
26270
  options: options2,
25758
26271
  missing
25759
26272
  });
26273
+ await closeAfter(row, flow.id, req.chain);
25760
26274
  if (weighty) {
25761
26275
  await deps.notify(
25762
26276
  row.id,
@@ -25770,18 +26284,24 @@ ${notes.join("\n\n")}` : "";
25770
26284
  decision: clampProse(verdict.decision, LEAD_DECISION_MAX),
25771
26285
  reason: `${clampProse(verdict.reason, LEAD_REASON_MAX)}${tail3}`,
25772
26286
  reply: verdict.reply,
26287
+ choices,
25773
26288
  choice,
25774
26289
  missing
25775
26290
  });
25776
26291
  return row;
25777
26292
  }
26293
+ async function closeAfter(row, flowId, chain) {
26294
+ await markFlowStep(flowId, row.status === "berlaku");
26295
+ if (!chain) await closeFlow(flowId, "tunggal");
26296
+ return row;
26297
+ }
25778
26298
  var lastDelivery = /* @__PURE__ */ new Map();
25779
26299
  function takeDelivery(decisionId) {
25780
26300
  const v = lastDelivery.get(decisionId) ?? null;
25781
26301
  lastDelivery.delete(decisionId);
25782
26302
  return v;
25783
26303
  }
25784
- async function fail(req, deps, reason) {
26304
+ async function fail(req, deps, reason, flowId = null, step = null) {
25785
26305
  const prev = await prisma.leadDecision.findFirst({
25786
26306
  where: { projectId: req.projectId, gate: req.gate, kind: req.kind },
25787
26307
  orderBy: { createdAt: "desc" },
@@ -25799,6 +26319,10 @@ async function fail(req, deps, reason) {
25799
26319
  refs: [],
25800
26320
  confidence: "ragu",
25801
26321
  action: "none",
26322
+ // SPEC-485 · langkah yang gagal tetap duduk di alurnya: alur yang seluruh langkahnya gagal
26323
+ // harus tetap terbaca sebagai satu urusan, bukan sebagai baris yatim.
26324
+ flowId,
26325
+ step,
25802
26326
  status: "gagal",
25803
26327
  weighty: true
25804
26328
  });
@@ -26044,7 +26568,7 @@ var prodDetectDeps = {
26044
26568
  return true;
26045
26569
  }
26046
26570
  },
26047
- send: (id, text) => sendToPane(id, text),
26571
+ send: (id, text, choices) => sendToPane(id, text, 50, choices),
26048
26572
  clearMarker: (file) => {
26049
26573
  try {
26050
26574
  writeFileSync5(file, "");
@@ -26208,7 +26732,8 @@ async function runChain(s2, agent, deps) {
26208
26732
  return { acted, done: false, failed: true, reason: "lead tak menghasilkan keputusan yang berlaku" };
26209
26733
  const sent = deps.delivery(row.id);
26210
26734
  const reply = (sent ? leadReplyText(sent) : "") || row.answer;
26211
- if (!await deps.send(s2.id, reply))
26735
+ const picked = sent?.choices.map((c) => c.option) ?? [];
26736
+ if (!await deps.send(s2.id, reply, picked))
26212
26737
  return { acted, done: false, failed: true, reason: "gagal mengetik ke pane" };
26213
26738
  acted = true;
26214
26739
  if (!screen) return { acted, done: true, failed: false, reason: "" };
@@ -26531,6 +27056,21 @@ async function tick2(now, deps = {}) {
26531
27056
  busyDetect = false;
26532
27057
  }));
26533
27058
  }
27059
+ jobs.push((async () => {
27060
+ const expire = deps.expire ?? expireFlows;
27061
+ const notify = deps.notify ?? recordLeadDecision;
27062
+ for (const f of await expire(/* @__PURE__ */ new Date())) {
27063
+ await notify(
27064
+ f.id,
27065
+ `Rantai keputusan lead ditutup karena kedaluwarsa: ${f.title.slice(0, 80)}`,
27066
+ f.projectId,
27067
+ f.specId,
27068
+ f.sessionId
27069
+ );
27070
+ }
27071
+ })().catch((e) => {
27072
+ console.error("lead expire:", e);
27073
+ }));
26534
27074
  if (!cfg.paused && !busyPulse && now - Math.max(lastPulseAt, pulseEndedAt) >= cfg.everyMin * 6e4) {
26535
27075
  busyPulse = true;
26536
27076
  lastPulseAt = now;
@@ -26618,11 +27158,35 @@ async function lead_default(app2) {
26618
27158
  specId: q.specId,
26619
27159
  sessionId: q.sessionId,
26620
27160
  status: q.status,
27161
+ // SPEC-485 · satu rantai dibaca lewat filter ini, urut NAIK (lihat `listDecisions`).
27162
+ flowId: q.flowId,
26621
27163
  take: q.take ? Number(q.take) : void 0,
26622
27164
  skip: q.skip ? Number(q.skip) : void 0
26623
27165
  });
26624
27166
  return { items: rows.map(toDecisionView) };
26625
27167
  });
27168
+ app2.get("/lead/flows", async (req) => {
27169
+ const q = req.query;
27170
+ const rows = await listFlows({
27171
+ projectId: q.projectId,
27172
+ status: q.status,
27173
+ take: q.take ? Number(q.take) : void 0,
27174
+ skip: q.skip ? Number(q.skip) : void 0
27175
+ });
27176
+ return { items: rows.map(toFlowView) };
27177
+ });
27178
+ app2.post("/lead/flows/:id/submit", async (req, reply) => {
27179
+ const { id } = req.params;
27180
+ const row = await closeFlow(id, "submit");
27181
+ if (!row) return reply.code(409).send({ error: "rantai tak ada atau sudah tertutup" });
27182
+ return toFlowView(row);
27183
+ });
27184
+ app2.post("/lead/flows/:id/cancel", async (req, reply) => {
27185
+ const { id } = req.params;
27186
+ const row = await closeFlow(id, "operator");
27187
+ if (!row) return reply.code(409).send({ error: "rantai tak ada atau sudah tertutup" });
27188
+ return toFlowView(row);
27189
+ });
26626
27190
  app2.post("/lead/decisions", async (req, reply) => {
26627
27191
  const parsed = zLeadAsk.safeParse(req.body);
26628
27192
  if (!parsed.success) return reply.code(400).send({ error: parsed.error.flatten() });
@@ -26632,6 +27196,15 @@ async function lead_default(app2) {
26632
27196
  const cfg = await getLead();
26633
27197
  if (!project.leadOptIn || !leadActive(cfg, ask.projectId))
26634
27198
  return reply.code(409).send({ error: "lead tidak aktif untuk project ini" });
27199
+ const optionCount = ask.options.length;
27200
+ if (ask.select.mode === "multi" && optionCount === 0)
27201
+ return reply.code(400).send({ error: "select.mode `multi` menuntut daftar `options`" });
27202
+ if (ask.select.max !== null && ask.select.min > ask.select.max)
27203
+ return reply.code(400).send({ error: "select.min melebihi select.max" });
27204
+ if (ask.select.max !== null && optionCount > 0 && ask.select.max > optionCount)
27205
+ return reply.code(400).send({ error: `select.max (${ask.select.max}) melebihi jumlah opsi (${optionCount})` });
27206
+ if (ask.select.min > optionCount)
27207
+ return reply.code(400).send({ error: `select.min (${ask.select.min}) melebihi jumlah opsi (${optionCount})` });
26635
27208
  let row;
26636
27209
  try {
26637
27210
  row = await decide({
@@ -26642,9 +27215,13 @@ async function lead_default(app2) {
26642
27215
  kind: "answer",
26643
27216
  question: ask.question,
26644
27217
  options: ask.options,
26645
- notes: ask.context ? [ask.context] : void 0
27218
+ notes: ask.context ? [ask.context] : void 0,
27219
+ select: ask.select,
27220
+ chain: ask.chain,
27221
+ flowId: ask.flowId ?? null
26646
27222
  });
26647
27223
  } catch (e) {
27224
+ if (e instanceof LeadFlowClosedError) return reply.code(409).send({ error: e.message });
26648
27225
  if (!(e instanceof LeadBusyError)) throw e;
26649
27226
  return reply.code(503).header("retry-after", String(Math.max(5, cfg.queueWaitSec))).send({ error: e.message, retryable: true, queued: e.queued });
26650
27227
  }
@@ -26657,6 +27234,7 @@ async function lead_default(app2) {
26657
27234
  } catch {
26658
27235
  }
26659
27236
  }
27237
+ const flowStatus = row.flowId ? (await prisma.leadFlow.findUnique({ where: { id: row.flowId }, select: { status: true } }))?.status ?? null : null;
26660
27238
  const answer = {
26661
27239
  id: row.id,
26662
27240
  decision: sent?.decision ?? row.answer,
@@ -26666,7 +27244,12 @@ async function lead_default(app2) {
26666
27244
  action: row.action,
26667
27245
  // Saluran pengiriman bisa meleset (baris lahir dari jalur lain); kolomnya yang selalu ada.
26668
27246
  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) : [])
27247
+ missing: sent?.missing ?? (Array.isArray(row.missing) ? row.missing.map(String) : []),
27248
+ // SPEC-485 · `choices` bentuk yang berlaku; `toDecisionView` sudah tahu cara menurunkannya
27249
+ // dari kolom lama, jadi pemetaannya tak diduplikasi di sini (kelas bug SPEC-431/448/475).
27250
+ choices: sent?.choices ?? toDecisionView(row).choices,
27251
+ flowId: row.flowId,
27252
+ flowStatus
26670
27253
  };
26671
27254
  return reply.code(201).send(answer);
26672
27255
  });
@@ -26674,12 +27257,17 @@ async function lead_default(app2) {
26674
27257
  const { id } = req.params;
26675
27258
  const parsed = zLeadOverride.safeParse(req.body);
26676
27259
  if (!parsed.success) return reply.code(400).send({ error: parsed.error.flatten() });
26677
- const r = await overrideDecision(id, parsed.data.answer, parsed.data.reason);
27260
+ const r = await overrideDecision(id, parsed.data.answer, parsed.data.reason, parsed.data.choices);
26678
27261
  if (!r) return reply.code(409).send({ error: "keputusan tak ada atau sudah tak berlaku" });
26679
27262
  let delivered = false;
26680
27263
  if (r.next.sessionId) {
26681
27264
  resetSession(r.next.sessionId);
26682
- delivered = await sendToPane(r.next.sessionId, parsed.data.answer).catch(() => false);
27265
+ delivered = await sendToPane(
27266
+ r.next.sessionId,
27267
+ parsed.data.answer,
27268
+ 50,
27269
+ toDecisionView(r.next).choices.map((c) => c.option)
27270
+ ).catch(() => false);
26683
27271
  }
26684
27272
  return { old: toDecisionView(r.old), next: toDecisionView(r.next), delivered };
26685
27273
  });
@@ -26696,11 +27284,61 @@ async function lead_default(app2) {
26696
27284
  init_src();
26697
27285
  init_db();
26698
27286
 
27287
+ // src/services/agent-tool-catalog.ts
27288
+ init_src();
27289
+ import { readFileSync as readFileSync12 } from "node:fs";
27290
+ import { homedir as homedir8 } from "node:os";
27291
+ import { join as join15 } from "node:path";
27292
+ var home = () => process.env.HOME || homedir8();
27293
+ var readJson = (path) => {
27294
+ try {
27295
+ return JSON.parse(readFileSync12(path, "utf8"));
27296
+ } catch {
27297
+ return null;
27298
+ }
27299
+ };
27300
+ var serversOf = (node) => {
27301
+ const ms = node?.mcpServers;
27302
+ if (!ms || typeof ms !== "object" || Array.isArray(ms)) return [];
27303
+ return Object.keys(ms);
27304
+ };
27305
+ var codexServers = () => {
27306
+ let text;
27307
+ try {
27308
+ text = readFileSync12(join15(home(), ".codex", "config.toml"), "utf8");
27309
+ } catch {
27310
+ return [];
27311
+ }
27312
+ const out3 = [];
27313
+ for (const m of text.matchAll(/^\s*\[mcp_servers\.(?:"([^"]+)"|([A-Za-z0-9_-]+))(?:\.[^\]]*)?\]/gm)) {
27314
+ const name2 = m[1] ?? m[2];
27315
+ if (name2) out3.push(name2);
27316
+ }
27317
+ return out3;
27318
+ };
27319
+ function mcpServerNames(repoDir) {
27320
+ const names = [];
27321
+ const claudeJson = readJson(join15(home(), ".claude.json"));
27322
+ names.push(...serversOf(claudeJson));
27323
+ if (repoDir) {
27324
+ const projects = claudeJson?.projects;
27325
+ if (projects && typeof projects === "object") names.push(...serversOf(projects[repoDir]));
27326
+ names.push(...serversOf(readJson(join15(repoDir, ".mcp.json"))));
27327
+ }
27328
+ names.push(...codexServers());
27329
+ return [...new Set(names.filter(Boolean))].sort((a, b) => a.localeCompare(b));
27330
+ }
27331
+ function agentToolCatalog(repoDir) {
27332
+ return [ALL_TOOLS_ENTRY, ...BUILTIN_AGENT_TOOLS, ...mcpServerNames(repoDir).map(mcpToolEntry)];
27333
+ }
27334
+ var agentToolIds = (repoDir) => agentToolCatalog(repoDir).map((t) => t.id);
27335
+
26699
27336
  // src/services/custom-agents.ts
26700
27337
  init_db();
26701
27338
  init_src();
26702
27339
  init_pty();
26703
27340
  var cache4 = [];
27341
+ var repoDirCache = /* @__PURE__ */ new Map();
26704
27342
  var asCustomAgent = (r) => ({
26705
27343
  id: r.id,
26706
27344
  projectId: r.projectId,
@@ -26710,6 +27348,7 @@ var asCustomAgent = (r) => ({
26710
27348
  tools: toolsOf(r.tools),
26711
27349
  model: r.model,
26712
27350
  mentions: mentionsOf(r.mentions),
27351
+ runtime: runtimeOf(r.runtime),
26713
27352
  enabled: r.enabled,
26714
27353
  createdAt: "",
26715
27354
  updatedAt: ""
@@ -26718,18 +27357,31 @@ var asCustomAgent = (r) => ({
26718
27357
  async function loadCustomAgents() {
26719
27358
  try {
26720
27359
  cache4 = await prisma.customAgent.findMany();
27360
+ const projects = await prisma.project.findMany({ select: { id: true, repoDir: true } });
27361
+ const bindings = await prisma.localBinding.findMany({ select: { projectId: true, repoDir: true } });
27362
+ const next = /* @__PURE__ */ new Map();
27363
+ for (const p of projects) next.set(p.id, p.repoDir ?? null);
27364
+ for (const b of bindings) next.set(b.projectId, b.repoDir ?? null);
27365
+ repoDirCache = next;
26721
27366
  } catch {
26722
27367
  cache4 = [];
27368
+ repoDirCache = /* @__PURE__ */ new Map();
26723
27369
  }
26724
27370
  }
26725
- function agentDefsFor(projectId) {
27371
+ function agentDefsFor(projectId, agent) {
26726
27372
  const globals = cache4.filter((r) => r.projectId === null).map(asCustomAgent);
26727
27373
  const project = cache4.filter((r) => r.projectId === projectId).map(asCustomAgent);
26728
- return effectiveAgents(globals, project).map((a) => ({
27374
+ const eff = effectiveAgents(globals, project).filter((a) => a.runtime === null || a.runtime === agent);
27375
+ const needsCatalog = eff.some((a) => (a.tools ?? []).includes(ALL_TOOLS));
27376
+ const catalogIds = needsCatalog ? agentToolIds(repoDirCache.get(projectId) ?? null) : [];
27377
+ return eff.map((a) => ({
26729
27378
  name: a.name,
26730
27379
  description: a.description,
26731
27380
  instructions: a.instructions,
26732
- tools: a.tools,
27381
+ // Ekspansi terjadi DI SINI, sebelum `resolveTools` di runner: meneruskan `"*"` apa adanya
27382
+ // membuat claude membuangnya senyap (agen tanpa alat), sementara menerjemahkannya jadi `null`
27383
+ // membuat agen mewarisi SELURUH tool termasuk `Task` — lapis 2 anti-loop lenyap tanpa jejak.
27384
+ tools: expandTools(a.tools, catalogIds),
26733
27385
  model: a.model,
26734
27386
  mentions: a.mentions ?? []
26735
27387
  }));
@@ -26753,7 +27405,7 @@ function unknownMentions(row, all) {
26753
27405
  }
26754
27406
  async function installCustomAgents() {
26755
27407
  await loadCustomAgents();
26756
- registerCustomAgentSource((projectId) => agentDefsFor(projectId));
27408
+ registerCustomAgentSource((projectId, agent) => agentDefsFor(projectId, agent));
26757
27409
  }
26758
27410
 
26759
27411
  // src/routes/custom-agents.ts
@@ -26767,10 +27419,33 @@ var view6 = (r, projectId) => ({
26767
27419
  tools: toolsOf(r.tools),
26768
27420
  model: r.model,
26769
27421
  mentions: mentionsOf(r.mentions),
27422
+ runtime: runtimeOf(r.runtime),
26770
27423
  enabled: r.enabled,
26771
27424
  ...projectId ? { inherited: r.projectId === null } : {}
26772
27425
  });
27426
+ var repoDirOf2 = async (projectId) => projectId ? await resolveRepoDir(projectId) : null;
27427
+ function toolsProblem(tools, catalogIds) {
27428
+ if (!tools || tools.length === 0) return null;
27429
+ if (tools.includes(ALL_TOOLS) && tools.length > 1) {
27430
+ return { error: "pintasan * harus jadi satu-satunya pilihan tools", unknownTools: [] };
27431
+ }
27432
+ const unknownTools = tools.filter((t) => !catalogIds.includes(t));
27433
+ return unknownTools.length ? { error: "tool tak dikenal di mesin ini", unknownTools } : null;
27434
+ }
27435
+ function modelProblem(model, runtime) {
27436
+ if (!model) return null;
27437
+ const ok2 = modelsForRuntime(runtime).some((m) => m.id === model);
27438
+ return ok2 ? null : { error: "model tak dikenal untuk runtime ini", model, runtime };
27439
+ }
26773
27440
  async function custom_agents_default(app2) {
27441
+ app2.get("/custom-agents/catalog", async (req) => {
27442
+ const projectId = req.query.projectId;
27443
+ return {
27444
+ tools: agentToolCatalog(await repoDirOf2(projectId)),
27445
+ models: modelsForRuntime(null),
27446
+ runtimes: AGENT_RUNTIMES.map((id) => ({ id, label: AGENT_RUNTIME_LABELS[id] }))
27447
+ };
27448
+ });
26774
27449
  app2.get("/custom-agents", async (req) => {
26775
27450
  const projectId = req.query.projectId;
26776
27451
  const rows = await prisma.customAgent.findMany({
@@ -26789,6 +27464,10 @@ async function custom_agents_default(app2) {
26789
27464
  const projectId = p.projectId ?? null;
26790
27465
  if (projectId && !await prisma.project.findUnique({ where: { id: projectId } }))
26791
27466
  return reply.code(400).send({ error: "project tak ditemukan", projectId });
27467
+ const tp = toolsProblem(p.tools ?? null, agentToolIds(await repoDirOf2(projectId)));
27468
+ if (tp) return reply.code(400).send(tp);
27469
+ const mp = modelProblem(p.model ?? null, p.runtime ?? null);
27470
+ if (mp) return reply.code(400).send(mp);
26792
27471
  const id = customAgentId(projectId, p.name);
26793
27472
  if (await prisma.customAgent.findUnique({ where: { id } }))
26794
27473
  return reply.code(409).send({ error: "nama sudah dipakai di scope ini", id });
@@ -26801,6 +27480,7 @@ async function custom_agents_default(app2) {
26801
27480
  tools: p.tools ?? null,
26802
27481
  model: p.model ?? null,
26803
27482
  mentions: p.mentions ?? [],
27483
+ runtime: p.runtime ?? null,
26804
27484
  enabled: p.enabled ?? true
26805
27485
  };
26806
27486
  const all = [...await rowsOf(), candidate];
@@ -26817,6 +27497,7 @@ async function custom_agents_default(app2) {
26817
27497
  tools: candidate.tools,
26818
27498
  model: candidate.model,
26819
27499
  mentions: candidate.mentions,
27500
+ runtime: candidate.runtime,
26820
27501
  enabled: candidate.enabled
26821
27502
  } });
26822
27503
  await loadCustomAgents();
@@ -26833,11 +27514,24 @@ async function custom_agents_default(app2) {
26833
27514
  const existing = await prisma.customAgent.findUnique({ where: { id } });
26834
27515
  if (!existing) return reply.code(404).send({ error: "not found" });
26835
27516
  const before = existing;
27517
+ const effRuntime = "runtime" in parsed.data ? parsed.data.runtime ?? null : runtimeOf(before.runtime);
27518
+ if (parsed.data.tools !== void 0) {
27519
+ const tp = toolsProblem(parsed.data.tools, agentToolIds(await repoDirOf2(before.projectId)));
27520
+ if (tp) return reply.code(400).send(tp);
27521
+ }
27522
+ if (parsed.data.model !== void 0 || "runtime" in parsed.data) {
27523
+ const mp = modelProblem(
27524
+ parsed.data.model !== void 0 ? parsed.data.model : before.model,
27525
+ effRuntime
27526
+ );
27527
+ if (mp) return reply.code(400).send(mp);
27528
+ }
26836
27529
  const candidate = {
26837
27530
  ...before,
26838
27531
  ...parsed.data,
26839
27532
  mentions: parsed.data.mentions ?? mentionsOf(before.mentions),
26840
- tools: parsed.data.tools !== void 0 ? parsed.data.tools : toolsOf(before.tools)
27533
+ tools: parsed.data.tools !== void 0 ? parsed.data.tools : toolsOf(before.tools),
27534
+ runtime: effRuntime
26841
27535
  };
26842
27536
  const all = (await rowsOf()).map((r) => r.id === id ? candidate : r);
26843
27537
  const unknown = unknownMentions(candidate, all);
@@ -26850,6 +27544,7 @@ async function custom_agents_default(app2) {
26850
27544
  tools: candidate.tools,
26851
27545
  model: candidate.model,
26852
27546
  mentions: candidate.mentions,
27547
+ runtime: candidate.runtime,
26853
27548
  enabled: candidate.enabled
26854
27549
  } });
26855
27550
  await loadCustomAgents();
@@ -26900,24 +27595,24 @@ function githubSlugFromUrl(url2) {
26900
27595
  async function resolveGithubRepo(projectId) {
26901
27596
  const project = await prisma.project.findUnique({ where: { id: projectId } });
26902
27597
  if (!project) return { ok: false, kind: "no-project", error: `project "${projectId}" tidak ada` };
26903
- const candidates = [];
26904
- if (project.gitRemote) candidates.push(project.gitRemote);
27598
+ const candidates2 = [];
27599
+ if (project.gitRemote) candidates2.push(project.gitRemote);
26905
27600
  const repoDir = await resolveRepoDir(projectId).catch(() => null);
26906
27601
  if (repoDir) {
26907
27602
  const origin = (await listRemotes(repoDir)).find((r) => r.name === "origin");
26908
- if (origin?.fetch) candidates.push(origin.fetch);
27603
+ if (origin?.fetch) candidates2.push(origin.fetch);
26909
27604
  }
26910
- if (candidates.length === 0)
27605
+ if (candidates2.length === 0)
26911
27606
  return {
26912
27607
  ok: false,
26913
27608
  kind: "no-remote",
26914
27609
  error: "project belum punya remote GitHub (isi gitRemote atau tambahkan origin di repo lokalnya)"
26915
27610
  };
26916
- for (const url2 of candidates) {
27611
+ for (const url2 of candidates2) {
26917
27612
  const gh = githubSlugFromUrl(url2);
26918
27613
  if (gh) return { ok: true, repo: gh };
26919
27614
  }
26920
- const host2 = parse2(candidates[0])?.host ?? candidates[0];
27615
+ const host2 = parse2(candidates2[0])?.host ?? candidates2[0];
26921
27616
  return {
26922
27617
  ok: false,
26923
27618
  kind: "not-github",
@@ -27349,7 +28044,7 @@ async function testTelegramConnection(deps = {}) {
27349
28044
  }
27350
28045
  const timeoutMs = deps.timeoutMs ?? 1e4;
27351
28046
  const controller = new AbortController();
27352
- const timer6 = setTimeout(() => controller.abort(), timeoutMs);
28047
+ const timer7 = setTimeout(() => controller.abort(), timeoutMs);
27353
28048
  const send = deps.transport ?? ((url2, init) => fetch(url2, init));
27354
28049
  const transport = (url2, init) => send(url2, { ...init, signal: controller.signal });
27355
28050
  const client = new TelegramApiClient(botToken, transport);
@@ -27363,7 +28058,7 @@ async function testTelegramConnection(deps = {}) {
27363
28058
  const message = controller.signal.aborted ? `Timeout ${timeoutMs} ms \u2014 Telegram tidak menjawab.` : raw;
27364
28059
  return { ok: false, error: sanitizeTelegramOutput(message, [botToken]).slice(0, 500) };
27365
28060
  } finally {
27366
- clearTimeout(timer6);
28061
+ clearTimeout(timer7);
27367
28062
  }
27368
28063
  }
27369
28064
  function resolveTestChatId() {
@@ -27669,7 +28364,7 @@ async function sendOnce(o, deps = {}) {
27669
28364
  nowSec: o.nowSec ?? Math.floor(Date.now() / 1e3)
27670
28365
  });
27671
28366
  const ac = new AbortController();
27672
- const timer6 = setTimeout(() => ac.abort(), WEBHOOK_TIMEOUT_MS);
28367
+ const timer7 = setTimeout(() => ac.abort(), WEBHOOK_TIMEOUT_MS);
27673
28368
  try {
27674
28369
  const f = deps.fetcher ?? ((u, init) => fetch(u, init));
27675
28370
  const res = await f(parsed.url.toString(), { method: "POST", headers, body: o.body, signal: ac.signal });
@@ -27683,7 +28378,7 @@ async function sendOnce(o, deps = {}) {
27683
28378
  const msg = e.name === "AbortError" ? `timeout ${WEBHOOK_TIMEOUT_MS} ms` : e.message;
27684
28379
  return fail2(msg);
27685
28380
  } finally {
27686
- clearTimeout(timer6);
28381
+ clearTimeout(timer7);
27687
28382
  }
27688
28383
  }
27689
28384
 
@@ -28885,6 +29580,120 @@ function startWebhookEngine() {
28885
29580
  }).catch((e) => console.error("webhook reset:", e));
28886
29581
  }
28887
29582
 
29583
+ // src/services/auto-merge.ts
29584
+ init_src();
29585
+ init_db();
29586
+ import { execFile as execFile11 } from "node:child_process";
29587
+ import { promisify as promisify10 } from "node:util";
29588
+ var AUTO_MERGE_WINDOW_MS = 24 * 60 * 60 * 1e3;
29589
+ var AUTO_MERGE_GRACE_MS = 15 * 60 * 1e3;
29590
+ var TICK_MS4 = 6e4;
29591
+ var exec8 = promisify10(execFile11);
29592
+ var GIT7 = { timeout: 3e4, maxBuffer: 1 << 24, encoding: "utf8" };
29593
+ var gitOut = async (cwd, args) => {
29594
+ try {
29595
+ return (await exec8("git", args, { cwd, ...GIT7 })).stdout.trim();
29596
+ } catch {
29597
+ return null;
29598
+ }
29599
+ };
29600
+ var prodAutoMergeDeps = {
29601
+ repoDir: resolveRepoDir,
29602
+ defaultBranch,
29603
+ // Tanpa `fetch`: `git push` dari worktree repo yang sama memperbarui remote-tracking ref
29604
+ // di repo itu juga, jadi origin/<branch> sudah mutakhir tepat setelah sesi mem-push.
29605
+ 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}`]),
29606
+ // `merge-base --is-ancestor` keluar 0/1 tanpa output → "" saat benar, null saat salah/galat.
29607
+ contains: async (repoDir, tip, sha) => await gitOut(repoDir, ["merge-base", "--is-ancestor", "--end-of-options", sha, tip]) !== null,
29608
+ integrate,
29609
+ discardWorktree: discardMergeWorktree,
29610
+ deleteBranch: deleteMergedBranch
29611
+ };
29612
+ async function candidates(now) {
29613
+ const since = new Date(now.getTime() - AUTO_MERGE_WINDOW_MS);
29614
+ const done = await prisma.notification.findMany({
29615
+ where: { type: "done", createdAt: { gte: since }, specId: { not: null } },
29616
+ select: { specId: true, projectId: true, createdAt: true }
29617
+ });
29618
+ if (!done.length) return [];
29619
+ const keys = done.map((d) => `automerge:${d.specId}`);
29620
+ const marked2 = new Set(
29621
+ (await prisma.notification.findMany({ where: { key: { in: keys } }, select: { key: true } })).map((n2) => n2.key)
29622
+ );
29623
+ return done.filter((d) => !marked2.has(`automerge:${d.specId}`)).map((d) => ({ specId: d.specId, projectId: d.projectId ?? "", doneAt: d.createdAt }));
29624
+ }
29625
+ async function sweepAutoMerge(deps = prodAutoMergeDeps, now = /* @__PURE__ */ new Date()) {
29626
+ const list2 = await candidates(now);
29627
+ if (!list2.length) return 0;
29628
+ let settled = 0;
29629
+ for (const c of list2) {
29630
+ try {
29631
+ if (await settleOne(c, deps, now)) settled++;
29632
+ } catch (e) {
29633
+ console.error(`auto-merge ${c.specId}:`, e);
29634
+ }
29635
+ }
29636
+ return settled;
29637
+ }
29638
+ async function settleOne(c, deps, now) {
29639
+ const spec = await prisma.spec.findUnique({ where: { id: c.specId } });
29640
+ if (!spec || spec.stage !== "done") return false;
29641
+ const project = await prisma.project.findUnique({ where: { id: spec.projectId } });
29642
+ if (!project) return false;
29643
+ const policy = resolveAutoMerge(
29644
+ project.autoMerge,
29645
+ spec.autoMerge
29646
+ );
29647
+ if (policy.mode === "off") return false;
29648
+ const report = (t) => recordAutoMerge(spec.id, spec.projectId, t).then(() => true);
29649
+ const repoDir = await deps.repoDir(spec.projectId);
29650
+ if (!repoDir)
29651
+ return report(`Auto-merge ${spec.id} dilewati \u2014 project belum di-bind ke checkout lokal`);
29652
+ const target2 = autoMergeTargetOf(
29653
+ policy,
29654
+ policy.mode === "default-branch" ? await deps.defaultBranch(repoDir) : null
29655
+ );
29656
+ if (!target2)
29657
+ return report(`Auto-merge ${spec.id} dilewati \u2014 default branch repo tak bisa diresolve; pilih branch tujuan di Settings project`);
29658
+ const branch = sourceBranch(spec.id);
29659
+ const tip = await deps.sourceTip(repoDir, branch);
29660
+ const ready = tip !== null && (spec.headSha === null || await deps.contains(repoDir, tip, spec.headSha));
29661
+ if (!ready) {
29662
+ if (now.getTime() - c.doneAt.getTime() <= AUTO_MERGE_GRACE_MS) return false;
29663
+ return report(`Auto-merge ${spec.id} dilewati \u2014 branch kerja \`${branch}\` belum ter-push ke origin`);
29664
+ }
29665
+ const res = await deps.integrate(repoDir, spec.id, "merge", target2);
29666
+ if (res.status === "clean") {
29667
+ if (policy.deleteBranch) await deps.deleteBranch(repoDir, branch).catch(() => {
29668
+ });
29669
+ return report(`Auto-merge ${spec.id} \u2192 ${target2} bersih (${res.detail})`);
29670
+ }
29671
+ if (res.status === "conflict") {
29672
+ await deps.discardWorktree(repoDir, res.worktree).catch(() => {
29673
+ });
29674
+ return report(`Auto-merge ${spec.id} \u2192 ${target2} GAGAL: konflik \u2014 branch kerja \`${branch}\` utuh, selesaikan lewat Rebase / Merge di backlog`);
29675
+ }
29676
+ return report(`Auto-merge ${spec.id} \u2192 ${target2} GAGAL: ${res.error}`);
29677
+ }
29678
+ var timer6;
29679
+ var busy3 = false;
29680
+ async function tick5() {
29681
+ if (busy3) return;
29682
+ busy3 = true;
29683
+ try {
29684
+ await sweepAutoMerge();
29685
+ } catch (e) {
29686
+ console.error("auto-merge sweep:", e);
29687
+ } finally {
29688
+ busy3 = false;
29689
+ }
29690
+ }
29691
+ function startAutoMerge() {
29692
+ if (timer6) return;
29693
+ timer6 = setInterval(() => void tick5(), TICK_MS4);
29694
+ timer6.unref();
29695
+ }
29696
+
28888
29697
  // src/server.ts
28889
29698
  var app = buildApp();
28890
29699
  var port = Number(process.env.PORT ?? 8787);
@@ -28931,6 +29740,7 @@ app.listen({ port, host }).then(async () => {
28931
29740
  startScheduler();
28932
29741
  startLead();
28933
29742
  startWebhookEngine();
29743
+ startAutoMerge();
28934
29744
  }).catch((err) => {
28935
29745
  console.error("listen gagal:", err);
28936
29746
  process.exit(1);