hanoman 0.1.7 → 0.1.9

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
@@ -4182,7 +4182,7 @@ function cmpVersion(a, b) {
4182
4182
  }
4183
4183
  return 0;
4184
4184
  }
4185
- var zProject, zBriefPayload, zQaPayload, zGoalPayload, zSpec, NOTIFY_SOUNDS, E_5_6, E_LUNA, E_BASE, CODEX_MODELS, RETIRED_CODEX_MODELS, zCodex, CODEX_DEFAULTS, zSourceCommon, zScheduler, SCHEDULER_DEFAULTS, zGoal, GOAL_DEFAULTS, zConflict, CONFLICT_DEFAULTS, zLeadEngine, zLead, LEAD_DEFAULTS, zSetting, zNotification, zDocFile, zDeviceTokenView;
4185
+ 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;
4186
4186
  var init_entities = __esm({
4187
4187
  "../shared/src/entities.ts"() {
4188
4188
  "use strict";
@@ -4225,6 +4225,10 @@ var init_entities = __esm({
4225
4225
  constraints: external_exports.string(),
4226
4226
  priority: zPriority
4227
4227
  });
4228
+ zSpecBlocker = external_exports.object({
4229
+ id: external_exports.string(),
4230
+ reason: external_exports.enum(["missing", "unfinished", "unmerged"])
4231
+ });
4228
4232
  zSpec = external_exports.object({
4229
4233
  id: external_exports.string(),
4230
4234
  projectId: external_exports.string(),
@@ -4243,7 +4247,13 @@ var init_entities = __esm({
4243
4247
  // SPEC-408 · ADR-0090 · stempel waktu backlog (ISO string di wire — kolom DateTime di DB).
4244
4248
  // `startedAt` null = belum pernah dikerjakan; ia tak pernah ditulis ulang saat sesi dilanjutkan.
4245
4249
  createdAt: external_exports.string(),
4246
- startedAt: external_exports.string().nullable()
4250
+ startedAt: external_exports.string().nullable(),
4251
+ // SPEC-447 · ADR-0093 · id backlog yang harus selesai & ter-merge lebih dulu. Server selalu
4252
+ // menormalkannya ke array (kolom DB-nya `Json?`); `.default([])` menjaga respons lama.
4253
+ dependsOn: external_exports.array(external_exports.string()).default([]),
4254
+ // Turunan (bukan kolom): dihitung `liveSpecs` dari stage dependency + git. Klien tak pernah
4255
+ // mengirimkannya — `.default([])` supaya bentuk lama tetap parse.
4256
+ blockedBy: external_exports.array(zSpecBlocker).default([])
4247
4257
  });
4248
4258
  NOTIFY_SOUNDS = [
4249
4259
  "off",
@@ -4436,7 +4446,12 @@ var init_agent = __esm({
4436
4446
  // baris jejak & bisa menggerakkan sesi), dan SPEC-405 sudah membuktikan apa yang terjadi saat
4437
4447
  // endpoint tulis menumpang prefix status yang dipetakan tanpa melihat method (AC-5).
4438
4448
  "lead:read",
4439
- "lead:write"
4449
+ "lead:write",
4450
+ // SPEC-450 · ADR-0094 · custom agent. Domain TERSENDIRI, dipetakan MENURUT METHOD: menulis
4451
+ // definisi agen mengubah apa yang dilihat SETIAP sesi baru di seluruh workspace, jadi izin
4452
+ // baca tak pernah cukup untuk itu (kelas bug SPEC-405).
4453
+ "agents:read",
4454
+ "agents:write"
4440
4455
  ];
4441
4456
  zCapability = external_exports.enum(CAPABILITY_IDS);
4442
4457
  zCapabilityInfo = external_exports.object({
@@ -4467,7 +4482,9 @@ var init_agent = __esm({
4467
4482
  { id: "notifications:read", domain: "notifications", access: "read", label: "Notifikasi \u2014 baca", desc: "Lihat notifikasi." },
4468
4483
  { id: "notifications:write", domain: "notifications", access: "write", label: "Notifikasi \u2014 tulis", desc: "Tandai terbaca / bersihkan notifikasi." },
4469
4484
  { id: "lead:read", domain: "lead", access: "read", label: "Lead \u2014 baca", desc: "Baca jejak keputusan hanoman-lead & statusnya." },
4470
- { id: "lead:write", domain: "lead", access: "write", label: "Lead \u2014 tulis", desc: "Minta putusan ke hanoman-lead (keputusan bisa menggerakkan sesi).", risk: "exec" }
4485
+ { id: "lead:write", domain: "lead", access: "write", label: "Lead \u2014 tulis", desc: "Minta putusan ke hanoman-lead (keputusan bisa menggerakkan sesi).", risk: "exec" },
4486
+ { id: "agents:read", domain: "agents", access: "read", label: "Custom agent \u2014 baca", desc: "Lihat katalog custom agent global & per project." },
4487
+ { id: "agents:write", domain: "agents", access: "write", label: "Custom agent \u2014 tulis", desc: "Buat/ubah/hapus custom agent; definisinya dipakai setiap sesi baru.", risk: "exec" }
4471
4488
  ];
4472
4489
  zAgentTokenView = external_exports.object({
4473
4490
  id: external_exports.string(),
@@ -4492,6 +4509,103 @@ var init_agent = __esm({
4492
4509
  }
4493
4510
  });
4494
4511
 
4512
+ // ../shared/src/custom-agent.ts
4513
+ function mentionsOf(v) {
4514
+ if (!Array.isArray(v)) return [];
4515
+ const out3 = [];
4516
+ for (const x of v) if (typeof x === "string" && x && !out3.includes(x)) out3.push(x);
4517
+ return out3;
4518
+ }
4519
+ function toolsOf(v) {
4520
+ if (!Array.isArray(v)) return null;
4521
+ const out3 = [];
4522
+ for (const x of v) if (typeof x === "string" && x && !out3.includes(x)) out3.push(x);
4523
+ return out3;
4524
+ }
4525
+ function resolveTools(a) {
4526
+ const base = a.tools ?? [...DEFAULT_AGENT_TOOLS];
4527
+ const canMention = (a.mentions ?? []).length > 0;
4528
+ const out3 = base.filter((t) => t !== MENTION_TOOL);
4529
+ if (canMention) out3.push(MENTION_TOOL);
4530
+ return out3;
4531
+ }
4532
+ function detectCycle(nodes) {
4533
+ const edges = new Map(nodes.map((n) => [n.name, n.mentions]));
4534
+ const state = /* @__PURE__ */ new Map();
4535
+ const stack = [];
4536
+ const walk = (name2) => {
4537
+ if (state.get(name2) === 1) return [...stack.slice(stack.indexOf(name2)), name2];
4538
+ if (state.get(name2) === 2) return null;
4539
+ state.set(name2, 1);
4540
+ stack.push(name2);
4541
+ for (const next of edges.get(name2) ?? []) {
4542
+ if (!edges.has(next)) continue;
4543
+ const found = walk(next);
4544
+ if (found) return found;
4545
+ }
4546
+ stack.pop();
4547
+ state.set(name2, 2);
4548
+ return null;
4549
+ };
4550
+ for (const n of nodes) {
4551
+ const found = walk(n.name);
4552
+ if (found) return found;
4553
+ }
4554
+ return null;
4555
+ }
4556
+ function effectiveAgents(globals, project) {
4557
+ const byName = /* @__PURE__ */ new Map();
4558
+ for (const a of globals) byName.set(a.name, a);
4559
+ for (const a of project) byName.set(a.name, a);
4560
+ return [...byName.values()].filter((a) => a.enabled).sort((x, y) => x.name.localeCompare(y.name));
4561
+ }
4562
+ var AGENT_NAME_RE, DEFAULT_AGENT_TOOLS, MENTION_TOOL, MENTION_MAX_HOPS, GLOBAL_SCOPE, zCustomAgent, zCreateCustomAgent, zUpdateCustomAgent, customAgentId;
4563
+ var init_custom_agent = __esm({
4564
+ "../shared/src/custom-agent.ts"() {
4565
+ "use strict";
4566
+ init_zod();
4567
+ AGENT_NAME_RE = /^[a-z][a-z0-9-]{1,39}$/;
4568
+ DEFAULT_AGENT_TOOLS = [
4569
+ "Read",
4570
+ "Write",
4571
+ "Edit",
4572
+ "Bash",
4573
+ "Glob",
4574
+ "Grep",
4575
+ "WebFetch",
4576
+ "WebSearch"
4577
+ ];
4578
+ MENTION_TOOL = "Task";
4579
+ MENTION_MAX_HOPS = 3;
4580
+ GLOBAL_SCOPE = "global";
4581
+ zCustomAgent = external_exports.object({
4582
+ id: external_exports.string(),
4583
+ projectId: external_exports.string().nullable(),
4584
+ name: external_exports.string().regex(AGENT_NAME_RE),
4585
+ description: external_exports.string(),
4586
+ instructions: external_exports.string(),
4587
+ tools: external_exports.array(external_exports.string()).nullable(),
4588
+ model: external_exports.string().nullable(),
4589
+ mentions: external_exports.array(external_exports.string()).nullable(),
4590
+ enabled: external_exports.boolean(),
4591
+ createdAt: external_exports.string(),
4592
+ updatedAt: external_exports.string()
4593
+ });
4594
+ zCreateCustomAgent = external_exports.object({
4595
+ projectId: external_exports.string().nullable().optional(),
4596
+ name: external_exports.string().regex(AGENT_NAME_RE),
4597
+ description: external_exports.string().trim().min(1).max(500),
4598
+ instructions: external_exports.string().trim().min(1).max(2e4),
4599
+ tools: external_exports.array(external_exports.string()).nullable().optional(),
4600
+ model: external_exports.string().nullable().optional(),
4601
+ mentions: external_exports.array(external_exports.string()).nullable().optional(),
4602
+ enabled: external_exports.boolean().optional()
4603
+ });
4604
+ zUpdateCustomAgent = zCreateCustomAgent.omit({ name: true, projectId: true }).partial();
4605
+ customAgentId = (projectId, name2) => `${projectId ?? GLOBAL_SCOPE}:${name2}`;
4606
+ }
4607
+ });
4608
+
4495
4609
  // ../shared/src/lead.ts
4496
4610
  function leadActionAllowed(action) {
4497
4611
  return LEAD_ACTIONS.includes(action);
@@ -4650,7 +4764,9 @@ var init_dto = __esm({
4650
4764
  title: external_exports.string().min(1),
4651
4765
  priority: zPriority,
4652
4766
  payload: external_exports.union([zBriefPayload, zQaPayload, zGoalPayload]),
4653
- branchFrom: external_exports.string().min(1).optional()
4767
+ branchFrom: external_exports.string().min(1).optional(),
4768
+ // SPEC-447 · ADR-0093 · divalidasi server (id ada / satu project / bukan diri sendiri / non-siklus).
4769
+ dependsOn: external_exports.array(external_exports.string()).optional()
4654
4770
  }).superRefine((o, ctx) => {
4655
4771
  const shape = "severity" in o.payload ? "qa" : "goal" in o.payload ? "goal" : "brief";
4656
4772
  const want = o.source === "qa" ? "qa" : o.source === "goal" ? "goal" : "brief";
@@ -4664,8 +4780,12 @@ var init_dto = __esm({
4664
4780
  // SPEC-186 · edit konten selagi item belum dimulai. Ditolak server bila sudah mulai.
4665
4781
  title: external_exports.string().min(1).optional(),
4666
4782
  priority: zPriority.optional(),
4667
- payload: external_exports.union([zBriefPayload, zQaPayload, zGoalPayload]).optional()
4783
+ payload: external_exports.union([zBriefPayload, zQaPayload, zGoalPayload]).optional(),
4668
4784
  // SPEC-407 · +goal
4785
+ // SPEC-447 · ADR-0093 · SENGAJA di luar gerbang `editingContent` (SPEC-186): gerbang itu
4786
+ // melindungi konten yang sudah jadi dasar kerja sesi berjalan, sedangkan dependsOn hanya
4787
+ // menggerbangi peluncuran BERIKUTNYA. `[]` = kosongkan.
4788
+ dependsOn: external_exports.array(external_exports.string()).optional()
4669
4789
  });
4670
4790
  zIntegrate = external_exports.object({
4671
4791
  op: external_exports.enum(["merge", "rebase"]),
@@ -4861,7 +4981,10 @@ var init_dto = __esm({
4861
4981
  goal: external_exports.boolean().optional(),
4862
4982
  goalCondition: external_exports.string().max(4e3).optional(),
4863
4983
  agent: zAgent.optional(),
4864
- verifyScope: zVerifyScope.optional()
4984
+ verifyScope: zVerifyScope.optional(),
4985
+ // SPEC-447 · ADR-0093 — lewati gerbang dependency. Hanya jalur manusia; UI hanya
4986
+ // mengirimkannya sesudah operator melihat daftar pemblokirnya.
4987
+ force: external_exports.boolean().optional()
4865
4988
  })
4866
4989
  ]);
4867
4990
  zDocFileContent = external_exports.object({ content: external_exports.string() });
@@ -5095,6 +5218,9 @@ var init_api = __esm({
5095
5218
  leadDecisions: `${API}/lead/decisions`,
5096
5219
  leadDecisionOverride: (id) => `${API}/lead/decisions/${encodeURIComponent(id)}/override`,
5097
5220
  leadDecisionCancel: (id) => `${API}/lead/decisions/${encodeURIComponent(id)}/cancel`,
5221
+ // SPEC-450 · ADR-0094 · katalog custom agent. `?projectId=` → himpunan EFEKTIF (global+project).
5222
+ customAgents: `${API}/custom-agents`,
5223
+ customAgent: (id) => `${API}/custom-agents/${encodeURIComponent(id)}`,
5098
5224
  // SPEC-268 · ADR-0066 · pemicu sync manual (cookie-authed)
5099
5225
  syncNow: `${API}/sync/now`,
5100
5226
  // SPEC-270 · ADR-0067 · antrean konflik rekonsil (cookie-authed)
@@ -5415,6 +5541,7 @@ var init_src = __esm({
5415
5541
  init_enums();
5416
5542
  init_entities();
5417
5543
  init_agent();
5544
+ init_custom_agent();
5418
5545
  init_lead();
5419
5546
  init_dto();
5420
5547
  init_api();
@@ -5989,6 +6116,17 @@ var init_git = __esm({
5989
6116
  { cwd: repo, encoding: "utf8" }
5990
6117
  );
5991
6118
  return r.status === 0 ? r.stdout.trim() : null;
6119
+ },
6120
+ // SPEC-447 · `git merge-base --is-ancestor A B` = exit 0 (ya) / 1 (tidak) / lainnya (error).
6121
+ // `--end-of-options` menjaga ADR-0032: sha & ref datang dari DB/kolom, jangan sampai terbaca
6122
+ // sebagai flag. Ref yang tak resolve membuat git exit 128 → dibaca sebagai "belum" (fail-closed).
6123
+ isAncestor: (repo, sha, ref) => {
6124
+ const r = spawnSync(
6125
+ "git",
6126
+ ["merge-base", "--is-ancestor", "--end-of-options", sha, ref],
6127
+ { cwd: repo, encoding: "utf8" }
6128
+ );
6129
+ return r.status === 0;
5992
6130
  }
5993
6131
  };
5994
6132
  }
@@ -6172,6 +6310,74 @@ var init_agent_cli = __esm({
6172
6310
  }
6173
6311
  });
6174
6312
 
6313
+ // ../runner/src/custom-agents.ts
6314
+ function agentPromptOf(def2, roster) {
6315
+ const can = liveMentions(def2, roster);
6316
+ if (can.length === 0) {
6317
+ return `${def2.instructions}
6318
+
6319
+ ---
6320
+ Kamu TIDAK boleh mendelegasikan ke agen lain. Selesaikan sendiri lalu laporkan hasilnya.`;
6321
+ }
6322
+ const list2 = can.map((m) => `@${m}`).join(", ");
6323
+ return [
6324
+ def2.instructions,
6325
+ "",
6326
+ "---",
6327
+ `Kamu boleh mendelegasikan HANYA ke: ${list2}. Panggil lewat ${MENTION_TOOL} dengan nama agennya.`,
6328
+ `Anggaran rantai delegasi seluruh sesi ini ${MENTION_MAX_HOPS} hop. Bila kamu sudah berada di hop ke-${MENTION_MAX_HOPS}, JANGAN mendelegasikan lagi \u2014 selesaikan sendiri lalu laporkan.`,
6329
+ "Sebutkan hop keberapa kamu berada saat mendelegasikan, dan jangan pernah memanggil agen yang sudah ada di rantai yang membawamu ke sini."
6330
+ ].join("\n");
6331
+ }
6332
+ function renderAgentsJson(defs) {
6333
+ if (defs.length === 0) return "";
6334
+ const out3 = {};
6335
+ for (const d of defs) {
6336
+ out3[d.name] = {
6337
+ description: d.description,
6338
+ prompt: agentPromptOf(d, defs),
6339
+ tools: resolveTools({ tools: d.tools, mentions: d.mentions }),
6340
+ ...d.model ? { model: d.model } : {}
6341
+ };
6342
+ }
6343
+ return JSON.stringify(out3);
6344
+ }
6345
+ function agentRosterBlock(defs) {
6346
+ if (defs.length === 0) return "";
6347
+ const lines2 = [
6348
+ "",
6349
+ "## Custom agent hanoman",
6350
+ "",
6351
+ "Peran berikut tersedia untuk sesi ini. Saat sebuah tugas cocok dengan salah satunya, ADOPSI",
6352
+ "perannya (baca instruksinya, kerjakan dengan sudut pandang itu) lalu kembali ke peranmu sendiri.",
6353
+ "Jangan melahirkan proses agen baru.",
6354
+ ""
6355
+ ];
6356
+ for (const d of defs) {
6357
+ const can = liveMentions(d, defs);
6358
+ lines2.push(`### @${d.name} \u2014 ${d.description}`);
6359
+ lines2.push("");
6360
+ lines2.push(d.instructions);
6361
+ lines2.push("");
6362
+ lines2.push(
6363
+ can.length ? `Boleh berkonsultasi ke: ${can.map((m) => `@${m}`).join(", ")} (maks ${MENTION_MAX_HOPS} hop berantai).` : "Tidak boleh berkonsultasi ke peran lain."
6364
+ );
6365
+ lines2.push("");
6366
+ }
6367
+ return lines2.join("\n");
6368
+ }
6369
+ var liveMentions;
6370
+ var init_custom_agents = __esm({
6371
+ "../runner/src/custom-agents.ts"() {
6372
+ "use strict";
6373
+ init_src();
6374
+ liveMentions = (def2, roster) => {
6375
+ const names = new Set(roster.map((r) => r.name));
6376
+ return def2.mentions.filter((m) => names.has(m) && m !== def2.name);
6377
+ };
6378
+ }
6379
+ });
6380
+
6175
6381
  // ../runner/src/paths.ts
6176
6382
  import { homedir } from "node:os";
6177
6383
  import { dirname as dirname2, isAbsolute as isAbsolute2, join, resolve as resolve4 } from "node:path";
@@ -6232,6 +6438,7 @@ var init_src2 = __esm({
6232
6438
  init_goal_spec();
6233
6439
  init_codex_settings();
6234
6440
  init_agent_cli();
6441
+ init_custom_agents();
6235
6442
  init_verify_scope();
6236
6443
  init_paths();
6237
6444
  }
@@ -6361,6 +6568,74 @@ var init_session_phases = __esm({
6361
6568
  }
6362
6569
  });
6363
6570
 
6571
+ // src/services/tui-dialog.ts
6572
+ function readChoiceDialog(paneText2) {
6573
+ const lines2 = paneText2.split("\n").map((l) => l.trimEnd());
6574
+ let footer = -1;
6575
+ for (let i = lines2.length - 1; i >= 0; i--) {
6576
+ if (FOOTER.test(lines2[i] ?? "")) {
6577
+ footer = i;
6578
+ break;
6579
+ }
6580
+ }
6581
+ if (footer < 0) return null;
6582
+ const found = [];
6583
+ for (const line of lines2.slice(0, footer)) {
6584
+ const m = ROW.exec(line);
6585
+ if (m) found.push({ n: Number(m[1]), label: (m[2] ?? "").trim() });
6586
+ }
6587
+ if (found.length < 2) return null;
6588
+ const run2 = [];
6589
+ let expected = found[found.length - 1].n;
6590
+ for (let i = found.length - 1; i >= 0 && expected >= 1; i--) {
6591
+ const row = found[i];
6592
+ if (row.n !== expected) break;
6593
+ run2.unshift(row);
6594
+ expected -= 1;
6595
+ }
6596
+ if (run2.length < 2 || run2[0].n !== 1) return null;
6597
+ const rows = run2.map((r) => ({
6598
+ n: r.n,
6599
+ label: r.label,
6600
+ free: PLACEHOLDER.test(r.label),
6601
+ chat: CHAT_ROW.test(r.label)
6602
+ }));
6603
+ return {
6604
+ rows,
6605
+ freeIndex: rows.find((r) => r.free)?.n ?? null,
6606
+ options: rows.filter((r) => !r.free && !r.chat).map((r) => r.label)
6607
+ };
6608
+ }
6609
+ function freeTextFilled(paneText2, n) {
6610
+ const row = readChoiceDialog(paneText2)?.rows.find((r) => r.n === n);
6611
+ if (!row) return false;
6612
+ return row.label.length > 0 && !PLACEHOLDER.test(row.label);
6613
+ }
6614
+ async function answerChoiceDialog(io, freeIndex, line, chunkMs) {
6615
+ io.literal(String(freeIndex));
6616
+ await io.sleep(DIALOG_SETTLE_MS);
6617
+ for (const chunk of goalChunks(line)) {
6618
+ io.literal(chunk);
6619
+ await io.sleep(chunkMs);
6620
+ }
6621
+ await io.sleep(DIALOG_SETTLE_MS);
6622
+ if (!freeTextFilled(io.capture(), freeIndex)) return false;
6623
+ io.enter();
6624
+ return true;
6625
+ }
6626
+ var FOOTER, ROW, PLACEHOLDER, CHAT_ROW, DIALOG_SETTLE_MS;
6627
+ var init_tui_dialog = __esm({
6628
+ "src/services/tui-dialog.ts"() {
6629
+ "use strict";
6630
+ init_src2();
6631
+ FOOTER = /enter to (?:select|confirm)\b/i;
6632
+ ROW = /^\s*[❯>›]?\s*(\d{1,2})\.\s+(\S.*)$/;
6633
+ PLACEHOLDER = /^(?:type something\.?|other)$/i;
6634
+ CHAT_ROW = /^chat about this$/i;
6635
+ DIALOG_SETTLE_MS = 250;
6636
+ }
6637
+ });
6638
+
6364
6639
  // src/config.ts
6365
6640
  var config_exports = {};
6366
6641
  __export(config_exports, {
@@ -6470,6 +6745,9 @@ function listPanes() {
6470
6745
  function registerSessionHooks(h) {
6471
6746
  hooks = h;
6472
6747
  }
6748
+ function registerCustomAgentSource(fn) {
6749
+ customAgentSource = fn;
6750
+ }
6473
6751
  function sessionKind(o, projectId, cwd) {
6474
6752
  if (o.specId) return "spec";
6475
6753
  if (o.flow === "reverse" || o.flow === "prd" || o.flow === "scaffold" || o.flow === "breakdown") return o.flow;
@@ -6491,11 +6769,14 @@ function createSession(projectId, cwd, opts = {}) {
6491
6769
  const existing = getSession(id);
6492
6770
  if (existing && !existing.exited) return existing;
6493
6771
  if (existing) killSession(id);
6772
+ const agentForDefs = opts.agent ?? "claude";
6773
+ const customDefs = opts.command ? [] : customAgentsFor(projectId);
6774
+ const rosterBlock = agentForDefs === "codex" ? agentRosterBlock(customDefs) : "";
6494
6775
  let promptArg = "";
6495
6776
  if (!opts.command && opts.prompt) {
6496
6777
  const promptFile = promptFilePath(id);
6497
6778
  mkdirSync3(dirname4(promptFile), { recursive: true });
6498
- writeFileSync(promptFile, opts.prompt);
6779
+ writeFileSync(promptFile, opts.prompt + rosterBlock);
6499
6780
  promptArg = `"$(cat ${sq(promptFile)})"`;
6500
6781
  }
6501
6782
  const agent = opts.agent ?? "claude";
@@ -6517,6 +6798,15 @@ function createSession(projectId, cwd, opts = {}) {
6517
6798
  }), { mode: 493 });
6518
6799
  }
6519
6800
  const effort = agent === "codex" && opts.model && opts.effort ? coerceCodexEffort(opts.model, opts.effort) : opts.effort;
6801
+ let agentsFile;
6802
+ if (agent === "claude") {
6803
+ const json = renderAgentsJson(customDefs);
6804
+ if (json) {
6805
+ agentsFile = agentsFilePath(id);
6806
+ mkdirSync3(dirname4(agentsFile), { recursive: true });
6807
+ writeFileSync(agentsFile, json);
6808
+ }
6809
+ }
6520
6810
  const flags = agentFlags({
6521
6811
  agent,
6522
6812
  model: opts.model,
@@ -6525,7 +6815,8 @@ function createSession(projectId, cwd, opts = {}) {
6525
6815
  goal: opts.goal,
6526
6816
  goalGate
6527
6817
  }).map(sq).join(" ");
6528
- argv = [sq(agentBin(agent)), promptArg, flags].filter(Boolean).join(" ");
6818
+ const agentsArg = agentsFile ? `--agents "$(cat ${sq(agentsFile)})"` : "";
6819
+ argv = [sq(agentBin(agent)), promptArg, flags, agentsArg].filter(Boolean).join(" ");
6529
6820
  }
6530
6821
  const envPairs = [];
6531
6822
  if (!opts.command && agent === "claude") {
@@ -6630,11 +6921,23 @@ async function sendToPane(id, text, chunkMs = 50) {
6630
6921
  const line = text.replace(/\s*\r?\n\s*/g, " ").trim();
6631
6922
  if (!line) return false;
6632
6923
  try {
6924
+ const io = {
6925
+ capture: () => capturePane(id, DIALOG_CAPTURE_LINES),
6926
+ literal: (s) => {
6927
+ tmux("send-keys", "-t", name(id), "-l", s);
6928
+ },
6929
+ enter: () => {
6930
+ tmux("send-keys", "-t", name(id), "Enter");
6931
+ },
6932
+ sleep
6933
+ };
6934
+ const free = readChoiceDialog(io.capture())?.freeIndex ?? null;
6935
+ if (free !== null) return await answerChoiceDialog(io, free, line, chunkMs);
6633
6936
  for (const chunk of goalChunks(line)) {
6634
- tmux("send-keys", "-t", name(id), "-l", chunk);
6937
+ io.literal(chunk);
6635
6938
  await sleep(chunkMs);
6636
6939
  }
6637
- tmux("send-keys", "-t", name(id), "Enter");
6940
+ io.enter();
6638
6941
  return true;
6639
6942
  } catch {
6640
6943
  return false;
@@ -6728,7 +7031,7 @@ function end(id, code) {
6728
7031
  function pollPhases(p, a) {
6729
7032
  if (!p.flow || !p.phaseFile) return;
6730
7033
  const phases = readPhases(p.phaseFile, p.flow);
6731
- const complete = sessionComplete(phases, p.cwd, p.specId);
7034
+ const complete = paneComplete(p);
6732
7035
  const json = phaseKey(phases, complete);
6733
7036
  if (json === a.lastPhases) return;
6734
7037
  a.lastPhases = json;
@@ -6775,7 +7078,7 @@ function attach(id, c) {
6775
7078
  if (a.scrollback) c.send(frame({ t: "data", d: a.scrollback }));
6776
7079
  if (p.flow && p.phaseFile) {
6777
7080
  const phases = readPhases(p.phaseFile, p.flow);
6778
- const complete = sessionComplete(phases, p.cwd, p.specId);
7081
+ const complete = paneComplete(p);
6779
7082
  a.lastPhases = phaseKey(phases, complete);
6780
7083
  c.send(frame({ t: "phase", phases, complete }));
6781
7084
  }
@@ -6814,13 +7117,14 @@ function spawnPty(...args) {
6814
7117
  );
6815
7118
  }
6816
7119
  }
6817
- var socket, PREFIX, MAX_SCROLLBACK, POLL_MS, markerFilled, attached, claudeBin, shellBin, codexBin, agentBin, rootBypassEnv, frame, name, promptFilePath, goalGatePath, goalStatePath, NO_SERVER, TmuxError, sq, sessionIdForSpec, idFor, FMT, listSessions, liveDecisions, getSession, hooks, emitBirth, emitDeath, sleep, paneText, GOAL_ARMED_MARKERS, goalArmed, phaseKey, poll, detach;
7120
+ var socket, PREFIX, MAX_SCROLLBACK, POLL_MS, markerFilled, attached, claudeBin, shellBin, codexBin, agentBin, rootBypassEnv, frame, name, promptFilePath, goalGatePath, goalStatePath, agentsFilePath, NO_SERVER, TmuxError, sq, sessionIdForSpec, idFor, FMT, listSessions, liveDecisions, getSession, hooks, emitBirth, emitDeath, customAgentSource, customAgentsFor, sleep, paneText, DIALOG_CAPTURE_LINES, GOAL_ARMED_MARKERS, goalArmed, phaseKey, paneComplete, sessionFinished, poll, detach;
6818
7121
  var init_pty = __esm({
6819
7122
  "src/services/pty.ts"() {
6820
7123
  "use strict";
6821
7124
  init_src2();
6822
7125
  init_src();
6823
7126
  init_session_phases();
7127
+ init_tui_dialog();
6824
7128
  init_config2();
6825
7129
  socket = () => effectiveStr("HANOMAN_TMUX_SOCKET") ?? "hanoman";
6826
7130
  PREFIX = "hanoman-";
@@ -6844,6 +7148,7 @@ var init_pty = __esm({
6844
7148
  promptFilePath = (id) => `${tmpdir()}/hanoman-prompts/${id}`;
6845
7149
  goalGatePath = (id) => `${tmpdir()}/hanoman-goal-gates/${id}.sh`;
6846
7150
  goalStatePath = (id) => `${tmpdir()}/hanoman-goal-gates/${id}.count`;
7151
+ agentsFilePath = (id) => `${tmpdir()}/hanoman-agents/${id}.json`;
6847
7152
  NO_SERVER = /no server running|error connecting to/i;
6848
7153
  TmuxError = class extends Error {
6849
7154
  constructor(message, noServer) {
@@ -6896,7 +7201,17 @@ var init_pty = __esm({
6896
7201
  } catch {
6897
7202
  }
6898
7203
  };
6899
- sleep = (ms) => new Promise((r) => setTimeout(r, ms));
7204
+ customAgentSource = () => [];
7205
+ customAgentsFor = (projectId) => {
7206
+ try {
7207
+ return customAgentSource(projectId);
7208
+ } catch {
7209
+ return [];
7210
+ }
7211
+ };
7212
+ sleep = (ms) => new Promise((r) => {
7213
+ setTimeout(r, ms);
7214
+ });
6900
7215
  paneText = (id) => {
6901
7216
  try {
6902
7217
  return tmux("capture-pane", "-p", "-t", name(id));
@@ -6904,6 +7219,7 @@ var init_pty = __esm({
6904
7219
  return "";
6905
7220
  }
6906
7221
  };
7222
+ DIALOG_CAPTURE_LINES = 60;
6907
7223
  GOAL_ARMED_MARKERS = {
6908
7224
  claude: ["/goal"],
6909
7225
  codex: ["Goal active", "Pursuing goal", "Goal achieved"]
@@ -6913,6 +7229,11 @@ var init_pty = __esm({
6913
7229
  return GOAL_ARMED_MARKERS[agent].some((m) => text.includes(m));
6914
7230
  };
6915
7231
  phaseKey = (phases, complete) => JSON.stringify({ phases, complete });
7232
+ paneComplete = (p) => !!p.flow && !!p.phaseFile && sessionComplete(readPhases(p.phaseFile, p.flow), p.cwd, p.specId);
7233
+ sessionFinished = (id) => {
7234
+ const p = getSession(id);
7235
+ return !!p && paneComplete(p);
7236
+ };
6916
7237
  detach = (id, c) => {
6917
7238
  attached.get(id)?.clients.delete(c);
6918
7239
  };
@@ -7000,6 +7321,7 @@ var init_rename_project = __esm({
7000
7321
  var sync_exports = {};
7001
7322
  __export(sync_exports, {
7002
7323
  SYNCED: () => SYNCED,
7324
+ __FIELDS_FOR_TEST: () => __FIELDS_FOR_TEST,
7003
7325
  applyPush: () => applyPush,
7004
7326
  backfillFeed: () => backfillFeed,
7005
7327
  isEntity: () => isEntity,
@@ -7137,33 +7459,41 @@ async function upsertLocal(entity, id, version, data) {
7137
7459
  function setAcceptedHook(hook) {
7138
7460
  onAccepted = hook;
7139
7461
  }
7140
- var SYNCED, DELEGATE, FIELDS, DATE_FIELDS, onAccepted;
7462
+ var SYNCED, DELEGATE, FIELDS, DATE_FIELDS, onAccepted, __FIELDS_FOR_TEST;
7141
7463
  var init_sync = __esm({
7142
7464
  "src/services/sync.ts"() {
7143
7465
  "use strict";
7144
7466
  init_db();
7145
7467
  init_rename_project();
7146
- SYNCED = ["project", "spec", "vps", "sessionResult", "ticket", "ticketAttachment"];
7468
+ SYNCED = ["project", "spec", "vps", "sessionResult", "ticket", "ticketAttachment", "customAgent"];
7147
7469
  DELEGATE = {
7148
7470
  project: prisma.project,
7149
7471
  spec: prisma.spec,
7150
7472
  vps: prisma.vps,
7151
7473
  sessionResult: prisma.sessionResult,
7152
7474
  ticket: prisma.ticket,
7153
- ticketAttachment: prisma.ticketAttachment
7475
+ ticketAttachment: prisma.ticketAttachment,
7476
+ customAgent: prisma.customAgent
7154
7477
  };
7155
7478
  FIELDS = {
7156
7479
  project: ["name", "desc", "kind", "stack", "gitRemote", "updatedAt"],
7157
7480
  // SPEC-408 · ADR-0090 · createdAt/startedAt ikut menyeberang — sejajar baseSha/headSha. Tanpa
7158
7481
  // ini spec asal-hub mendapat createdAt lokal palsu di tiap client (kolom NOT NULL ber-default).
7159
- spec: ["projectId", "title", "source", "stage", "priority", "author", "objective", "payload", "branchFrom", "baseSha", "headSha", "createdAt", "startedAt", "updatedAt"],
7482
+ // SPEC-447 · ADR-0093 · dependsOn ikut juga: tanpa itu client tak tahu urutannya dan akan
7483
+ // meluncurkan pekerjaan yang di hub terblokir. Bukan DATE_FIELDS — nilainya array string.
7484
+ spec: ["projectId", "title", "source", "stage", "priority", "author", "objective", "payload", "branchFrom", "baseSha", "headSha", "dependsOn", "createdAt", "startedAt", "updatedAt"],
7160
7485
  vps: ["name", "host", "port", "user", "health", "audit", "hardened", "lastSeenAt", "lastAuditAt", "updatedAt"],
7161
7486
  sessionResult: ["projectId", "specId", "oldStage", "newStage", "commitSha", "branch", "prUrl", "status", "deviceId", "author", "createdAt", "updatedAt"],
7162
7487
  // SPEC-268 · ADR-0066 · metadata tiket (lampiran biner tak disync). accessKeyHash wajib
7163
7488
  // (kolom required @unique tanpa default); kunci plaintext tak pernah menyeberang.
7164
7489
  ticket: ["projectId", "number", "category", "title", "detail", "reporterEmail", "status", "accessKeyHash", "specId", "createdAt", "updatedAt"],
7165
7490
  // SPEC-272 · ADR-0068 · metadata lampiran (byte tak disync; ditarik lazy dari hub saat dibuka).
7166
- ticketAttachment: ["ticketId", "projectId", "filename", "mimeType", "size", "storageKey", "createdAt", "updatedAt"]
7491
+ ticketAttachment: ["ticketId", "projectId", "filename", "mimeType", "size", "storageKey", "createdAt", "updatedAt"],
7492
+ // SPEC-450 · ADR-0094 · SELURUH kolom bermakna ikut menyeberang. `enabled` & `mentions` wajib
7493
+ // ada: `upsert` yang tak menyebut kolom ber-default TETAP berhasil, jadi kolom yang terlewat
7494
+ // mendarat sebagai default palsu di tiap client tanpa satu pun error (kelas ADR-0090/0093).
7495
+ // `version` tak pernah masuk FIELDS — ia stempel mekanisme sync itu sendiri.
7496
+ customAgent: ["projectId", "name", "description", "instructions", "tools", "model", "mentions", "enabled", "createdAt", "updatedAt"]
7167
7497
  };
7168
7498
  DATE_FIELDS = {
7169
7499
  project: ["updatedAt"],
@@ -7171,8 +7501,10 @@ var init_sync = __esm({
7171
7501
  vps: ["lastSeenAt", "lastAuditAt", "updatedAt"],
7172
7502
  sessionResult: ["createdAt", "updatedAt"],
7173
7503
  ticket: ["createdAt", "updatedAt"],
7174
- ticketAttachment: ["createdAt", "updatedAt"]
7504
+ ticketAttachment: ["createdAt", "updatedAt"],
7505
+ customAgent: ["createdAt", "updatedAt"]
7175
7506
  };
7507
+ __FIELDS_FOR_TEST = FIELDS;
7176
7508
  }
7177
7509
  });
7178
7510
 
@@ -15360,6 +15692,128 @@ async function readEscalation(specId, sessions = listSessions()) {
15360
15692
  };
15361
15693
  }
15362
15694
 
15695
+ // src/services/spec-deps.ts
15696
+ init_db();
15697
+ init_src2();
15698
+ var REASON_LABEL = {
15699
+ missing: "tak ditemukan",
15700
+ unfinished: "belum selesai",
15701
+ unmerged: "belum ter-merge"
15702
+ };
15703
+ function dependsOnOf(spec) {
15704
+ const v = spec.dependsOn;
15705
+ if (!Array.isArray(v)) return [];
15706
+ const out3 = [];
15707
+ for (const x of v) if (typeof x === "string" && x !== "" && !out3.includes(x)) out3.push(x);
15708
+ return out3;
15709
+ }
15710
+ function blockersFor(spec, deps, isMerged) {
15711
+ const ids = dependsOnOf(spec);
15712
+ if (ids.length === 0) return [];
15713
+ const base = spec.branchFrom ?? "HEAD";
15714
+ const out3 = [];
15715
+ for (const id of ids) {
15716
+ const d = deps.get(id);
15717
+ if (!d) {
15718
+ out3.push({ id, reason: "missing" });
15719
+ continue;
15720
+ }
15721
+ if (d.stage !== "done") {
15722
+ out3.push({ id, reason: "unfinished" });
15723
+ continue;
15724
+ }
15725
+ if (d.headSha && !isMerged(d.headSha, base)) out3.push({ id, reason: "unmerged" });
15726
+ }
15727
+ return out3;
15728
+ }
15729
+ function blockedNote(bl) {
15730
+ return `menunggu ${bl.map((b) => `${b.id} (${REASON_LABEL[b.reason]})`).join(", ")}`;
15731
+ }
15732
+ function reaches(edges, from, target2) {
15733
+ const seen = /* @__PURE__ */ new Set();
15734
+ const stack = [...from];
15735
+ while (stack.length) {
15736
+ const cur = stack.pop();
15737
+ if (cur === target2) return true;
15738
+ if (seen.has(cur)) continue;
15739
+ seen.add(cur);
15740
+ for (const n of edges.get(cur) ?? []) stack.push(n);
15741
+ }
15742
+ return false;
15743
+ }
15744
+ var TTL_MS = 15e3;
15745
+ var mergeCache = /* @__PURE__ */ new Map();
15746
+ function mergedInto(repoDir, sha, baseRef) {
15747
+ const key = `${repoDir} ${sha} ${baseRef}`;
15748
+ const hit = mergeCache.get(key);
15749
+ const now = Date.now();
15750
+ if (hit && now - hit.at < TTL_MS) return hit.v;
15751
+ let v = false;
15752
+ try {
15753
+ v = realGit.isAncestor(repoDir, sha, baseRef);
15754
+ } catch {
15755
+ v = false;
15756
+ }
15757
+ mergeCache.set(key, { at: now, v });
15758
+ return v;
15759
+ }
15760
+ var merger = (repoDir) => (sha, base) => repoDir ? mergedInto(repoDir, sha, base) : false;
15761
+ var depRows = (ids) => prisma.spec.findMany({
15762
+ where: { id: { in: ids } },
15763
+ select: { id: true, stage: true, headSha: true }
15764
+ });
15765
+ async function blockersForSpec(spec, repoDir) {
15766
+ const ids = dependsOnOf(spec);
15767
+ if (ids.length === 0) return [];
15768
+ const rows = await depRows(ids);
15769
+ return blockersFor(spec, new Map(rows.map((r) => [r.id, r])), merger(repoDir));
15770
+ }
15771
+ async function decorateBlocked(specs) {
15772
+ const ids = [...new Set(specs.flatMap(dependsOnOf))];
15773
+ if (ids.length === 0) return specs.map((s) => ({ ...s, dependsOn: [], blockedBy: [] }));
15774
+ const rows = await depRows(ids);
15775
+ const deps = new Map(rows.map((r) => [r.id, r]));
15776
+ const repos = /* @__PURE__ */ new Map();
15777
+ const out3 = [];
15778
+ for (const s of specs) {
15779
+ const own = dependsOnOf(s);
15780
+ if (own.length === 0) {
15781
+ out3.push({ ...s, dependsOn: [], blockedBy: [] });
15782
+ continue;
15783
+ }
15784
+ if (!repos.has(s.projectId)) repos.set(s.projectId, await resolveRepoDir(s.projectId));
15785
+ out3.push({ ...s, dependsOn: own, blockedBy: blockersFor(s, deps, merger(repos.get(s.projectId))) });
15786
+ }
15787
+ return out3;
15788
+ }
15789
+ async function validateDependsOn(specId, projectId, raw) {
15790
+ const ids = dependsOnOf({ dependsOn: raw });
15791
+ if (ids.length === 0) return { ok: true, ids: [] };
15792
+ if (specId && ids.includes(specId))
15793
+ return { ok: false, error: "backlog tak bisa bergantung pada dirinya sendiri" };
15794
+ const rows = await prisma.spec.findMany({
15795
+ where: { id: { in: ids } },
15796
+ select: { id: true, projectId: true }
15797
+ });
15798
+ const found = new Map(rows.map((r) => [r.id, r.projectId]));
15799
+ const missing = ids.filter((i) => !found.has(i));
15800
+ if (missing.length) return { ok: false, error: `backlog tak ditemukan: ${missing.join(", ")}` };
15801
+ const foreign = ids.filter((i) => found.get(i) !== projectId);
15802
+ if (foreign.length)
15803
+ return { ok: false, error: `dependency harus di project yang sama: ${foreign.join(", ")}` };
15804
+ if (specId) {
15805
+ const all = await prisma.spec.findMany({
15806
+ where: { projectId },
15807
+ select: { id: true, dependsOn: true }
15808
+ });
15809
+ const edges = new Map(all.map((r) => [r.id, dependsOnOf(r)]));
15810
+ edges.set(specId, ids);
15811
+ if (reaches(edges, ids, specId))
15812
+ return { ok: false, error: "dependency membentuk siklus" };
15813
+ }
15814
+ return { ok: true, ids };
15815
+ }
15816
+
15363
15817
  // src/services/doc-export.ts
15364
15818
  import PDFDocument from "pdfkit";
15365
15819
 
@@ -17829,7 +18283,7 @@ async function liveSpecs(filter = {}) {
17829
18283
  orderBy: { id: "desc" }
17830
18284
  });
17831
18285
  const live = sessionPhasesBySpec();
17832
- if (live.size === 0) return specs;
18286
+ if (live.size === 0) return decorateBlocked(specs);
17833
18287
  const advanced = [];
17834
18288
  const doneNow = [];
17835
18289
  const out3 = specs.map((s) => {
@@ -17847,7 +18301,7 @@ async function liveSpecs(filter = {}) {
17847
18301
  if (res.count > 0) await notifySynced("spec", a.id);
17848
18302
  }));
17849
18303
  await Promise.all(doneNow.map((d) => recordCompletion(d.specId, d.title, d.projectId)));
17850
- return out3;
18304
+ return decorateBlocked(out3);
17851
18305
  }
17852
18306
 
17853
18307
  // src/routes/specs.ts
@@ -17887,6 +18341,8 @@ async function specs_default(app2) {
17887
18341
  const repoDir = await resolveRepoDir(b.project);
17888
18342
  if (b.branchFrom && await branchUnknown(repoDir, b.branchFrom))
17889
18343
  return reply.code(400).send({ error: `branch "${b.branchFrom}" tidak ada di repo project` });
18344
+ const dep = await validateDependsOn(null, b.project, b.dependsOn ?? []);
18345
+ if (!dep.ok) return reply.code(400).send({ error: dep.error });
17890
18346
  const isQa = b.source === "qa";
17891
18347
  const { priority, objective } = deriveSpecFields(b.source, b.payload, b.priority);
17892
18348
  const author = req.user?.email ?? "system";
@@ -17905,7 +18361,9 @@ async function specs_default(app2) {
17905
18361
  author: isQa ? `QA \xB7 ${author}` : b.source === "audit" ? `Audit \xB7 ${author}` : b.source === "goal" ? `Goal \xB7 ${author}` : author,
17906
18362
  objective,
17907
18363
  payload: b.payload,
17908
- branchFrom: b.branchFrom ?? null
18364
+ branchFrom: b.branchFrom ?? null,
18365
+ dependsOn: dep.ids
18366
+ // SPEC-447 · ADR-0093
17909
18367
  }
17910
18368
  });
17911
18369
  } catch (e) {
@@ -17969,7 +18427,7 @@ ${item.context}` : item.context;
17969
18427
  if (!parsed.success) return reply.code(400).send({ error: parsed.error.flatten() });
17970
18428
  const spec = await prisma.spec.findUnique({ where: { id } });
17971
18429
  if (!spec) return reply.code(404).send({ error: "not found" });
17972
- const { branchFrom, stage, confirmDelete, title, priority: newPriority, payload } = parsed.data;
18430
+ const { branchFrom, stage, confirmDelete, title, priority: newPriority, payload, dependsOn } = parsed.data;
17973
18431
  const editingContent = title !== void 0 || newPriority !== void 0 || payload !== void 0;
17974
18432
  if (editingContent && (spec.stage !== "brainstorming" || spec.baseSha !== null))
17975
18433
  return reply.code(409).send({ error: "backlog item sudah dimulai \u2014 tak bisa diedit" });
@@ -17977,6 +18435,12 @@ ${item.context}` : item.context;
17977
18435
  if (await branchUnknown(await resolveRepoDir(spec.projectId), branchFrom))
17978
18436
  return reply.code(400).send({ error: `branch "${branchFrom}" tidak ada di repo project` });
17979
18437
  }
18438
+ let depIds;
18439
+ if (dependsOn !== void 0) {
18440
+ const d = await validateDependsOn(spec.id, spec.projectId, dependsOn);
18441
+ if (!d.ok) return reply.code(400).send({ error: d.error });
18442
+ depIds = d.ids;
18443
+ }
17980
18444
  if (stage !== void 0) {
17981
18445
  if (STAGES.indexOf(stage) >= STAGES.indexOf(spec.stage))
17982
18446
  return reply.code(422).send({ error: "stage hanya boleh dikembalikan mundur" });
@@ -17988,6 +18452,7 @@ ${item.context}` : item.context;
17988
18452
  }
17989
18453
  const data = {};
17990
18454
  if (branchFrom !== void 0) data.branchFrom = branchFrom;
18455
+ if (depIds !== void 0) data.dependsOn = depIds;
17991
18456
  if (stage !== void 0) data.stage = stage;
17992
18457
  if (editingContent) {
17993
18458
  const effPayload = payload ?? spec.payload;
@@ -18020,8 +18485,21 @@ ${item.context}` : item.context;
18020
18485
  });
18021
18486
  app2.delete("/specs/:id", async (req, reply) => {
18022
18487
  const { id } = req.params;
18488
+ const gone = await prisma.spec.findUnique({ where: { id }, select: { projectId: true } });
18023
18489
  await prisma.spec.delete({ where: { id } }).catch(() => {
18024
18490
  });
18491
+ if (gone) {
18492
+ const rows = await prisma.spec.findMany({
18493
+ where: { projectId: gone.projectId },
18494
+ select: { id: true, dependsOn: true }
18495
+ });
18496
+ for (const r of rows) {
18497
+ const ids = dependsOnOf(r);
18498
+ if (!ids.includes(id)) continue;
18499
+ await prisma.spec.update({ where: { id: r.id }, data: { dependsOn: ids.filter((x) => x !== id) } });
18500
+ await notifySynced("spec", r.id);
18501
+ }
18502
+ }
18025
18503
  return reply.code(204).send();
18026
18504
  });
18027
18505
  app2.post("/specs/:id/integrate", async (req, reply) => {
@@ -19251,9 +19729,11 @@ init_pty();
19251
19729
  init_session_phases();
19252
19730
  init_pty();
19253
19731
  var LaunchError = class extends Error {
19254
- constructor(message, kind) {
19732
+ // SPEC-447 · `blockers` hanya terisi untuk kind "blocked"; route memetakannya ke body 409.
19733
+ constructor(message, kind, blockers = []) {
19255
19734
  super(message);
19256
19735
  this.kind = kind;
19736
+ this.blockers = blockers;
19257
19737
  }
19258
19738
  };
19259
19739
  function resumeState(repoDir, worktree, branchTo, headSha) {
@@ -19275,6 +19755,11 @@ async function startSpecSession(spec, opts) {
19275
19755
  const id = sessionIdForSpec(spec.id);
19276
19756
  const pane = getSession(id);
19277
19757
  if (pane && !pane.exited) return { id: pane.id, reused: true };
19758
+ if (!opts.force) {
19759
+ const blockers = await blockersForSpec(spec, repoDir);
19760
+ if (blockers.length)
19761
+ throw new LaunchError(`${spec.id} ${blockedNote(blockers)}`, "blocked", blockers);
19762
+ }
19278
19763
  if (pane) killSession(id);
19279
19764
  const setting = await getSetting();
19280
19765
  const agent = opts.agent ?? setting.agent;
@@ -19437,12 +19922,16 @@ async function terminal_default(app2) {
19437
19922
  // SPEC-332 · ADR-0073
19438
19923
  agent: parsed.data.agent,
19439
19924
  // SPEC-338 · ADR-0074
19440
- verifyScope: parsed.data.verifyScope
19925
+ verifyScope: parsed.data.verifyScope,
19441
19926
  // SPEC-376 · ADR-0080
19927
+ force: parsed.data.force
19928
+ // SPEC-447 · ADR-0093
19442
19929
  });
19443
19930
  return reply.code(201).send(r.resumed ? { id: r.id, resumed: true } : { id: r.id });
19444
19931
  } catch (e) {
19445
19932
  if (e instanceof LaunchError) {
19933
+ if (e.kind === "blocked")
19934
+ return reply.code(409).send({ error: e.message, blocked: true, blockers: e.blockers });
19446
19935
  return e.kind === "needs-bind" ? reply.code(400).send({ error: e.message, needsBind: true }) : reply.code(422).send({ error: e.message });
19447
19936
  }
19448
19937
  throw e;
@@ -20378,7 +20867,7 @@ import { readFileSync as readFileSync9 } from "node:fs";
20378
20867
  import { homedir as homedir6 } from "node:os";
20379
20868
  import { join as join9 } from "node:path";
20380
20869
  var USAGE_URL = "https://api.anthropic.com/api/oauth/usage";
20381
- var TTL_MS = 3e4;
20870
+ var TTL_MS2 = 3e4;
20382
20871
  var lastOk = null;
20383
20872
  var freshUntil = 0;
20384
20873
  var LABELS = {
@@ -20456,7 +20945,7 @@ async function fetchUsage() {
20456
20945
  async function getLimits() {
20457
20946
  if (lastOk && Date.now() < freshUntil) return lastOk;
20458
20947
  const dto = await fetchUsage();
20459
- if (dto.status === "ok") freshUntil = Date.now() + TTL_MS;
20948
+ if (dto.status === "ok") freshUntil = Date.now() + TTL_MS2;
20460
20949
  return dto;
20461
20950
  }
20462
20951
 
@@ -20464,7 +20953,7 @@ async function getLimits() {
20464
20953
  import { open as open2, readdir as readdir2, stat } from "node:fs/promises";
20465
20954
  import { homedir as homedir7 } from "node:os";
20466
20955
  import { join as join10 } from "node:path";
20467
- var TTL_MS2 = 3e4;
20956
+ var TTL_MS3 = 3e4;
20468
20957
  var STALE_AFTER_MS = 12 * 36e5;
20469
20958
  var TAIL_BYTES = 512 * 1024;
20470
20959
  var MAX_FILES = 8;
@@ -20569,7 +21058,7 @@ async function getCodexLimits(dir2) {
20569
21058
  const dto = await readLimits(codexSessionsDir());
20570
21059
  if (dto.status !== "unavailable") {
20571
21060
  cache2 = dto;
20572
- freshUntil2 = Date.now() + TTL_MS2;
21061
+ freshUntil2 = Date.now() + TTL_MS3;
20573
21062
  }
20574
21063
  return dto;
20575
21064
  }
@@ -20587,7 +21076,7 @@ import { execFile as execFile8 } from "node:child_process";
20587
21076
  import { promisify as promisify8 } from "node:util";
20588
21077
  var run = promisify8(execFile8);
20589
21078
  var CODEX_MIN_CLIENT = "0.144.0";
20590
- var TTL_MS3 = 5 * 6e4;
21079
+ var TTL_MS4 = 5 * 6e4;
20591
21080
  var cache3 = null;
20592
21081
  var codexBin2 = () => effectiveStr("HANOMAN_CODEX_BIN") ?? "codex";
20593
21082
  function parseCodexVersion(out3) {
@@ -20595,7 +21084,7 @@ function parseCodexVersion(out3) {
20595
21084
  return m ? `${m[1]}.${m[2]}.${m[3]}` : null;
20596
21085
  }
20597
21086
  async function getCodexVersion(now = Date.now()) {
20598
- if (cache3 && now - cache3.at < TTL_MS3) return cache3.version;
21087
+ if (cache3 && now - cache3.at < TTL_MS4) return cache3.version;
20599
21088
  let version = null;
20600
21089
  try {
20601
21090
  const { stdout } = await run(codexBin2(), ["--version"], { timeout: 5e3 });
@@ -21825,6 +22314,11 @@ async function markDone(id, note) {
21825
22314
  data: { status: "done", ...note ? { note } : {} }
21826
22315
  });
21827
22316
  }
22317
+ async function noteQueued(id, note) {
22318
+ const row = await prisma.schedulerQueueItem.findUnique({ where: { id }, select: { note: true } });
22319
+ if (row?.note === note) return;
22320
+ await prisma.schedulerQueueItem.update({ where: { id }, data: { note } });
22321
+ }
21828
22322
 
21829
22323
  // src/services/scheduler/registry.ts
21830
22324
  var sources = /* @__PURE__ */ new Map();
@@ -22113,6 +22607,7 @@ function toDecisionView(r) {
22113
22607
 
22114
22608
  // src/services/lead/brain.ts
22115
22609
  init_config2();
22610
+ init_pty();
22116
22611
  import { execFile as execFile9 } from "node:child_process";
22117
22612
  var binFor = (agent) => agent === "codex" ? effectiveStr("HANOMAN_CODEX_BIN") ?? "codex" : effectiveStr("HANOMAN_CLAUDE_BIN") ?? "claude";
22118
22613
  function leadArgv(o) {
@@ -22133,14 +22628,16 @@ function leadArgv(o) {
22133
22628
  o.prompt
22134
22629
  ];
22135
22630
  }
22631
+ var leadEnv = (agent, base = process.env, uid = process.getuid?.()) => agent === "claude" ? { ...rootBypassEnv(uid), ...base } : { ...base };
22136
22632
  function think(prompt, o) {
22137
22633
  const bin = binFor(o.agent);
22138
22634
  const args = leadArgv({ agent: o.agent, model: o.model, effort: o.effort, prompt });
22139
22635
  return new Promise((resolve14, reject) => {
22140
- execFile9(bin, args, {
22636
+ const child = execFile9(bin, args, {
22141
22637
  cwd: o.cwd,
22142
22638
  timeout: o.timeoutMs,
22143
22639
  maxBuffer: 16 * 1024 * 1024,
22640
+ env: leadEnv(o.agent),
22144
22641
  encoding: "utf8",
22145
22642
  killSignal: "SIGTERM"
22146
22643
  }, (err, stdout, stderr) => {
@@ -22151,6 +22648,7 @@ function think(prompt, o) {
22151
22648
  }
22152
22649
  resolve14(stdout);
22153
22650
  });
22651
+ child.stdin?.end();
22154
22652
  });
22155
22653
  }
22156
22654
 
@@ -22373,12 +22871,11 @@ async function integrateMain(row, deps) {
22373
22871
  if (!repoDir) return { ok: false, detail: `project ${row.projectId} belum di-bind ke checkout lokal` };
22374
22872
  const spec = await prisma.spec.findUnique({ where: { id: row.specId } });
22375
22873
  if (!spec) return { ok: false, detail: `backlog ${row.specId} tak ada` };
22874
+ const sid = sessionIdForSpec(spec.id);
22875
+ const done = deps.planDone(worktreeDir(repoDir, spec.id), spec.id);
22376
22876
  const evidence = [];
22377
22877
  if (cfg.requireGreenBeforeIntegrate) {
22378
- const wt = worktreeDir(repoDir, spec.id);
22379
- const done = deps.planDone(wt, spec.id);
22380
22878
  evidence.push(done ? "plan tak menyisakan `- [ ]`" : "plan MASIH menyisakan `- [ ]`");
22381
- const sid = sessionIdForSpec(spec.id);
22382
22879
  const pane = deps.sessionExists(sid);
22383
22880
  evidence.push(pane ? `pane ${sid} masih ada` : `pane ${sid} sudah tak ada`);
22384
22881
  if (!done) {
@@ -22397,6 +22894,9 @@ async function integrateMain(row, deps) {
22397
22894
  }
22398
22895
  const res = await deps.integrate(repoDir, spec.id, "merge", "local:main");
22399
22896
  evidence.push(`integrate \u2192 ${res.status}`);
22897
+ if (res.status === "clean" && done && deps.sessionExists(sid)) {
22898
+ evidence.push(deps.killSession(sid) ? `pane ${sid} dilepas (worktree utuh)` : `pane ${sid} gagal dilepas`);
22899
+ }
22400
22900
  await recordEvidence(row, evidence, res.status === "clean" ? "integrasi bersih" : `integrasi tak bersih: ${res.status}`);
22401
22901
  if (res.status !== "clean") {
22402
22902
  await deps.notify(
@@ -22421,8 +22921,10 @@ Bukti integrasi: ${evidence.join("; ")} \u2192 ${verdict}.` }
22421
22921
 
22422
22922
  // src/services/lead/detect.ts
22423
22923
  init_pty();
22924
+ import { writeFileSync as writeFileSync4 } from "node:fs";
22424
22925
 
22425
22926
  // src/services/lead/pane.ts
22927
+ init_tui_dialog();
22426
22928
  var CLEAN = /[│┃┆┊┌┐└┘├┤┬┴┼─━╭╮╰╯>❯]/g;
22427
22929
  var tail = (text, lines2) => text.split("\n").map((l) => l.replace(CLEAN, " ").trimEnd()).filter((l) => l.trim()).slice(-lines2);
22428
22930
  var CODEX_FINISHED = [
@@ -22439,15 +22941,16 @@ var ASK_SIGNALS = [
22439
22941
  function readPaneQuestion(text, agent) {
22440
22942
  const lines2 = tail(text, 40);
22441
22943
  const body = lines2.join("\n").trim();
22442
- if (!body) return { asking: false, question: "", reason: "layar kosong" };
22944
+ const choices = readChoiceDialog(text)?.options ?? [];
22945
+ if (!body) return { asking: false, question: "", reason: "layar kosong", choices };
22443
22946
  const question = tail(text, 25).join("\n").trim();
22444
22947
  if (agent === "codex") {
22445
22948
  const finished = CODEX_FINISHED.find((re) => re.test(body));
22446
- if (finished) return { asking: false, question, reason: "sesi codex selesai wajar (ADR-0074)" };
22949
+ if (finished) return { asking: false, question, reason: "sesi codex selesai wajar (ADR-0074)", choices };
22447
22950
  if (!ASK_SIGNALS.some((re) => re.test(body)))
22448
- return { asking: false, question, reason: "tak ada sinyal pertanyaan di layar codex" };
22951
+ return { asking: false, question, reason: "tak ada sinyal pertanyaan di layar codex", choices };
22449
22952
  }
22450
- return { asking: true, question, reason: "" };
22953
+ return { asking: true, question, reason: "", choices };
22451
22954
  }
22452
22955
 
22453
22956
  // src/services/lead/detect.ts
@@ -22485,6 +22988,12 @@ var prodDetectDeps = {
22485
22988
  }
22486
22989
  },
22487
22990
  send: (id, text) => sendToPane(id, text),
22991
+ clearMarker: (file) => {
22992
+ try {
22993
+ writeFileSync4(file, "");
22994
+ } catch {
22995
+ }
22996
+ },
22488
22997
  decide,
22489
22998
  decideDeps: prodDecideDeps,
22490
22999
  optIn: leadProjects,
@@ -22547,6 +23056,10 @@ async function scanAndAnswer(deps = prodDetectDeps) {
22547
23056
  skip(read.reason);
22548
23057
  continue;
22549
23058
  }
23059
+ const notes = [`Sesi ini menunggu di terminal; teks di bawah adalah layar terakhirnya. Jawablah sebagai masukan yang bisa langsung diketik ke terminal itu (isi \`reply\`).`];
23060
+ if (read.choices.length) {
23061
+ notes.push("Layarnya adalah dialog pilihan. `reply` akan dimasukkan sebagai JAWABAN BEBAS ke dialog itu, jadi tulislah jawaban yang berdiri sendiri \u2014 sebut opsi yang kamu pilih beserta alasan singkatnya, bukan nomornya saja.");
23062
+ }
22550
23063
  const row = await deps.decide({
22551
23064
  projectId: s.projectId,
22552
23065
  specId: s.specId,
@@ -22554,7 +23067,8 @@ async function scanAndAnswer(deps = prodDetectDeps) {
22554
23067
  gate: "detected",
22555
23068
  kind: "answer",
22556
23069
  question: read.question,
22557
- notes: [`Sesi ini menunggu di terminal; teks di bawah adalah layar terakhirnya. Jawablah sebagai masukan yang bisa langsung diketik ke terminal itu (isi \`reply\`).`]
23070
+ options: read.choices.length ? read.choices : void 0,
23071
+ notes
22558
23072
  }, deps.decideDeps);
22559
23073
  if (!row || row.status !== "berlaku") {
22560
23074
  skip("lead tak menghasilkan keputusan yang berlaku");
@@ -22566,6 +23080,7 @@ async function scanAndAnswer(deps = prodDetectDeps) {
22566
23080
  skip("gagal mengetik ke pane");
22567
23081
  continue;
22568
23082
  }
23083
+ deps.clearMarker(s.decisionFile);
22569
23084
  answers.set(s.id, (answers.get(s.id) ?? 0) + 1);
22570
23085
  out3.answered.push(s.id);
22571
23086
  }
@@ -22618,6 +23133,13 @@ var prodPulseDeps = {
22618
23133
  }
22619
23134
  },
22620
23135
  planDone: planComplete,
23136
+ finished: (id) => {
23137
+ try {
23138
+ return sessionFinished(id);
23139
+ } catch {
23140
+ return false;
23141
+ }
23142
+ },
22621
23143
  decide,
22622
23144
  decideDeps: prodDecideDeps,
22623
23145
  apply: applyAction,
@@ -22638,6 +23160,10 @@ async function pulse(deps = prodPulseDeps) {
22638
23160
  res.quality = await followUpFinished(cfg, optIn, deps);
22639
23161
  } catch {
22640
23162
  }
23163
+ try {
23164
+ res.quality += await followUpComplete(optIn, deps);
23165
+ } catch {
23166
+ }
22641
23167
  try {
22642
23168
  res.collisions = await detectCollisions(optIn, deps);
22643
23169
  } catch {
@@ -22690,6 +23216,49 @@ async function followUpFinished(cfg, optIn, deps) {
22690
23216
  }
22691
23217
  return n;
22692
23218
  }
23219
+ async function followUpComplete(optIn, deps) {
23220
+ let n = 0;
23221
+ const opt = new Set(optIn);
23222
+ for (const s of deps.sessions()) {
23223
+ if (!s.specId || !opt.has(s.projectId)) continue;
23224
+ if ((s.exitCode ?? 0) !== 0) continue;
23225
+ if (!deps.finished(s.id)) continue;
23226
+ const mark = `Backlog ${s.specId} sudah selesai di sesi ${s.id}`;
23227
+ const seen = await prisma.leadDecision.findFirst({
23228
+ where: { sessionId: s.id, gate: "pulse", question: { startsWith: mark } }
23229
+ });
23230
+ if (seen) continue;
23231
+ const row = await deps.decide({
23232
+ projectId: s.projectId,
23233
+ specId: s.specId,
23234
+ sessionId: s.id,
23235
+ gate: "pulse",
23236
+ kind: "quality",
23237
+ question: `${mark}: seluruh fasenya tercatat dan plan-nya tak menyisakan kotak \`- [ ]\`. Selama sesinya belum dilepas, ia memegang satu slot concurrency scheduler sehingga backlog lain tak bisa mulai. Integrasikan hasilnya ke main, hentikan sesinya saja, atau biarkan?`,
23238
+ options: [
23239
+ "integrate-main \u2014 merge branch sesi ini ke main; panenya ikut dilepas, worktree tetap utuh",
23240
+ "stop-session \u2014 lepas panenya tanpa mengintegrasikan (worktree tetap utuh, ADR-0084 masih bisa melanjutkan)",
23241
+ "none \u2014 biarkan sesinya berdiri, sertakan alasan kenapa slot itu layak ditahan"
23242
+ ],
23243
+ notes: [
23244
+ `Worktree sesi: ${s.cwd}`,
23245
+ // Rebase sengaja tak ditawarkan: `LEAD_ACTIONS` adalah konstanta tertutup (AC-31), dan
23246
+ // merge adalah yang PALING MUDAH DIBATALKAN dari keduanya — kriteria yang diperintahkan
23247
+ // prompt lead sendiri. Rebase tetap tindakan operator lewat POST /specs/:id/integrate.
23248
+ "Rebase tidak tersedia untukmu; bila hasilnya menuntut rebase, pilih `none` dan katakan begitu."
23249
+ ]
23250
+ }, deps.decideDeps);
23251
+ if (!row) continue;
23252
+ n++;
23253
+ if (row.status === "berlaku" && row.action !== "none") {
23254
+ try {
23255
+ await deps.apply(row);
23256
+ } catch {
23257
+ }
23258
+ }
23259
+ }
23260
+ return n;
23261
+ }
22693
23262
  async function detectCollisions(optIn, deps) {
22694
23263
  const opt = new Set(optIn);
22695
23264
  const live = deps.sessions().filter((s) => !s.exited && s.specId && opt.has(s.projectId));
@@ -22736,11 +23305,22 @@ async function orderReadyWork(optIn, deps) {
22736
23305
  async function orderProject(projectId, deps) {
22737
23306
  const project = await prisma.project.findUnique({ where: { id: projectId }, select: { schedulerOptIn: true } });
22738
23307
  if (!project?.schedulerOptIn) return 0;
22739
- const ready = await prisma.spec.findMany({
23308
+ const readyRaw = await prisma.spec.findMany({
22740
23309
  where: { ...UNSTARTED_SPEC_WHERE, projectId },
22741
- select: { id: true, projectId: true, title: true, priority: true, objective: true },
23310
+ select: {
23311
+ id: true,
23312
+ projectId: true,
23313
+ title: true,
23314
+ priority: true,
23315
+ objective: true,
23316
+ branchFrom: true,
23317
+ dependsOn: true
23318
+ },
22742
23319
  orderBy: { id: "asc" }
22743
23320
  });
23321
+ const repoDir = await resolveRepoDir(projectId);
23322
+ const ready = [];
23323
+ for (const r of readyRaw) if ((await blockersForSpec(r, repoDir)).length === 0) ready.push(r);
22744
23324
  if (ready.length < 2) return 0;
22745
23325
  const already = new Set((await prisma.schedulerQueueItem.findMany({
22746
23326
  where: { specId: { in: ready.map((r) => r.id) } },
@@ -22951,6 +23531,190 @@ async function lead_default(app2) {
22951
23531
  });
22952
23532
  }
22953
23533
 
23534
+ // src/routes/custom-agents.ts
23535
+ init_src();
23536
+ init_db();
23537
+
23538
+ // src/services/custom-agents.ts
23539
+ init_db();
23540
+ init_src();
23541
+ init_pty();
23542
+ var cache4 = [];
23543
+ var asCustomAgent = (r) => ({
23544
+ id: r.id,
23545
+ projectId: r.projectId,
23546
+ name: r.name,
23547
+ description: r.description,
23548
+ instructions: r.instructions,
23549
+ tools: toolsOf(r.tools),
23550
+ model: r.model,
23551
+ mentions: mentionsOf(r.mentions),
23552
+ enabled: r.enabled,
23553
+ createdAt: "",
23554
+ updatedAt: ""
23555
+ // tak dipakai lapis ini
23556
+ });
23557
+ async function loadCustomAgents() {
23558
+ try {
23559
+ cache4 = await prisma.customAgent.findMany();
23560
+ } catch {
23561
+ cache4 = [];
23562
+ }
23563
+ }
23564
+ function agentDefsFor(projectId) {
23565
+ const globals = cache4.filter((r) => r.projectId === null).map(asCustomAgent);
23566
+ const project = cache4.filter((r) => r.projectId === projectId).map(asCustomAgent);
23567
+ return effectiveAgents(globals, project).map((a) => ({
23568
+ name: a.name,
23569
+ description: a.description,
23570
+ instructions: a.instructions,
23571
+ tools: a.tools,
23572
+ model: a.model,
23573
+ mentions: a.mentions ?? []
23574
+ }));
23575
+ }
23576
+ function validateGraph(rows) {
23577
+ const projectScopes = [...new Set(rows.map((r) => r.projectId).filter((p) => p !== null))];
23578
+ const globals = rows.filter((r) => r.projectId === null).map(asCustomAgent);
23579
+ for (const scope of [null, ...projectScopes]) {
23580
+ const project = scope === null ? [] : rows.filter((r) => r.projectId === scope).map(asCustomAgent);
23581
+ const nodes = effectiveAgents(globals, project).map((a) => ({ name: a.name, mentions: a.mentions ?? [] }));
23582
+ const cycle = detectCycle(nodes);
23583
+ if (cycle) return { scope: scope ?? GLOBAL_SCOPE, cycle };
23584
+ }
23585
+ return null;
23586
+ }
23587
+ function unknownMentions(row, all) {
23588
+ const visible = new Set(
23589
+ all.filter((r) => r.projectId === null || row.projectId !== null && r.projectId === row.projectId).map((r) => r.name)
23590
+ );
23591
+ return mentionsOf(row.mentions).filter((m) => !visible.has(m));
23592
+ }
23593
+ async function installCustomAgents() {
23594
+ await loadCustomAgents();
23595
+ registerCustomAgentSource((projectId) => agentDefsFor(projectId));
23596
+ }
23597
+
23598
+ // src/routes/custom-agents.ts
23599
+ var rowsOf = async () => await prisma.customAgent.findMany();
23600
+ var view6 = (r, projectId) => ({
23601
+ id: r.id,
23602
+ projectId: r.projectId,
23603
+ name: r.name,
23604
+ description: r.description,
23605
+ instructions: r.instructions,
23606
+ tools: toolsOf(r.tools),
23607
+ model: r.model,
23608
+ mentions: mentionsOf(r.mentions),
23609
+ enabled: r.enabled,
23610
+ ...projectId ? { inherited: r.projectId === null } : {}
23611
+ });
23612
+ async function custom_agents_default(app2) {
23613
+ app2.get("/custom-agents", async (req) => {
23614
+ const projectId = req.query.projectId;
23615
+ const rows = await prisma.customAgent.findMany({
23616
+ where: projectId ? { OR: [{ projectId: null }, { projectId }] } : { projectId: null },
23617
+ orderBy: { name: "asc" }
23618
+ });
23619
+ const byName = /* @__PURE__ */ new Map();
23620
+ for (const r of rows) if (r.projectId === null) byName.set(r.name, r);
23621
+ for (const r of rows) if (r.projectId !== null) byName.set(r.name, r);
23622
+ return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name)).map((r) => view6(r, projectId));
23623
+ });
23624
+ app2.post("/custom-agents", async (req, reply) => {
23625
+ const parsed = zCreateCustomAgent.safeParse(req.body);
23626
+ if (!parsed.success) return reply.code(400).send({ error: parsed.error.flatten() });
23627
+ const p = parsed.data;
23628
+ const projectId = p.projectId ?? null;
23629
+ if (projectId && !await prisma.project.findUnique({ where: { id: projectId } }))
23630
+ return reply.code(400).send({ error: "project tak ditemukan", projectId });
23631
+ const id = customAgentId(projectId, p.name);
23632
+ if (await prisma.customAgent.findUnique({ where: { id } }))
23633
+ return reply.code(409).send({ error: "nama sudah dipakai di scope ini", id });
23634
+ const candidate = {
23635
+ id,
23636
+ projectId,
23637
+ name: p.name,
23638
+ description: p.description,
23639
+ instructions: p.instructions,
23640
+ tools: p.tools ?? null,
23641
+ model: p.model ?? null,
23642
+ mentions: p.mentions ?? [],
23643
+ enabled: p.enabled ?? true
23644
+ };
23645
+ const all = [...await rowsOf(), candidate];
23646
+ const unknown = unknownMentions(candidate, all);
23647
+ if (unknown.length) return reply.code(400).send({ error: "mention tak dikenal", unknown });
23648
+ const cycle = validateGraph(all);
23649
+ if (cycle) return reply.code(409).send({ error: "mention membentuk siklus", ...cycle });
23650
+ const row = await prisma.customAgent.create({ data: {
23651
+ id,
23652
+ projectId,
23653
+ name: p.name,
23654
+ description: p.description,
23655
+ instructions: p.instructions,
23656
+ tools: candidate.tools,
23657
+ model: candidate.model,
23658
+ mentions: candidate.mentions,
23659
+ enabled: candidate.enabled
23660
+ } });
23661
+ await loadCustomAgents();
23662
+ await notifySynced("customAgent", id);
23663
+ return reply.code(201).send(view6(row));
23664
+ });
23665
+ app2.patch("/custom-agents/:id", async (req, reply) => {
23666
+ const { id } = req.params;
23667
+ const body = req.body ?? {};
23668
+ if ("name" in body || "projectId" in body)
23669
+ return reply.code(400).send({ error: "name & projectId tak bisa diubah \u2014 hapus lalu buat baru" });
23670
+ const parsed = zUpdateCustomAgent.safeParse(body);
23671
+ if (!parsed.success) return reply.code(400).send({ error: parsed.error.flatten() });
23672
+ const existing = await prisma.customAgent.findUnique({ where: { id } });
23673
+ if (!existing) return reply.code(404).send({ error: "not found" });
23674
+ const before = existing;
23675
+ const candidate = {
23676
+ ...before,
23677
+ ...parsed.data,
23678
+ mentions: parsed.data.mentions ?? mentionsOf(before.mentions),
23679
+ tools: parsed.data.tools !== void 0 ? parsed.data.tools : toolsOf(before.tools)
23680
+ };
23681
+ const all = (await rowsOf()).map((r) => r.id === id ? candidate : r);
23682
+ const unknown = unknownMentions(candidate, all);
23683
+ if (unknown.length) return reply.code(400).send({ error: "mention tak dikenal", unknown });
23684
+ const cycle = validateGraph(all);
23685
+ if (cycle) return reply.code(409).send({ error: "mention membentuk siklus", ...cycle });
23686
+ const row = await prisma.customAgent.update({ where: { id }, data: {
23687
+ description: candidate.description,
23688
+ instructions: candidate.instructions,
23689
+ tools: candidate.tools,
23690
+ model: candidate.model,
23691
+ mentions: candidate.mentions,
23692
+ enabled: candidate.enabled
23693
+ } });
23694
+ await loadCustomAgents();
23695
+ await notifySynced("customAgent", id);
23696
+ return view6(row);
23697
+ });
23698
+ app2.delete("/custom-agents/:id", async (req, reply) => {
23699
+ const { id } = req.params;
23700
+ const existing = await prisma.customAgent.findUnique({ where: { id } });
23701
+ if (!existing) return reply.code(404).send({ error: "not found" });
23702
+ await prisma.customAgent.delete({ where: { id } });
23703
+ const name2 = existing.name;
23704
+ for (const r of await rowsOf()) {
23705
+ const m = mentionsOf(r.mentions);
23706
+ if (!m.includes(name2)) continue;
23707
+ await prisma.customAgent.update({
23708
+ where: { id: r.id },
23709
+ data: { mentions: m.filter((x) => x !== name2) }
23710
+ });
23711
+ await notifySynced("customAgent", r.id);
23712
+ }
23713
+ await loadCustomAgents();
23714
+ return reply.code(204).send();
23715
+ });
23716
+ }
23717
+
22954
23718
  // src/app.ts
22955
23719
  var import_multipart = __toESM(require_multipart2(), 1);
22956
23720
 
@@ -23032,7 +23796,7 @@ function cookieOpts() {
23032
23796
  }
23033
23797
 
23034
23798
  // src/routes/auth.ts
23035
- var view6 = (u) => ({ id: u.id, email: u.email, createdAt: u.createdAt.toISOString() });
23799
+ var view7 = (u) => ({ id: u.id, email: u.email, createdAt: u.createdAt.toISOString() });
23036
23800
  async function issue(reply, userId) {
23037
23801
  const token = await createSession2(userId);
23038
23802
  reply.setCookie(COOKIE_NAME, token, cookieOpts());
@@ -23050,7 +23814,7 @@ async function auth_default(app2) {
23050
23814
  data: { email: p.data.email, passwordHash: await hashPassword(p.data.password) }
23051
23815
  });
23052
23816
  await issue(reply, user.id);
23053
- return { user: view6(user) };
23817
+ return { user: view7(user) };
23054
23818
  });
23055
23819
  app2.post("/auth/login", async (req, reply) => {
23056
23820
  if (loginThrottle(req.ip).blocked) return reply.code(429).send({ error: "too many attempts" });
@@ -23063,7 +23827,7 @@ async function auth_default(app2) {
23063
23827
  }
23064
23828
  clearLoginFails(req.ip);
23065
23829
  await issue(reply, user.id);
23066
- return { user: view6(user) };
23830
+ return { user: view7(user) };
23067
23831
  });
23068
23832
  app2.post("/auth/logout", async (req, reply) => {
23069
23833
  const token = req.cookies?.[COOKIE_NAME];
@@ -23071,7 +23835,7 @@ async function auth_default(app2) {
23071
23835
  reply.clearCookie(COOKIE_NAME, { path: "/" });
23072
23836
  return reply.code(204).send();
23073
23837
  });
23074
- app2.get("/auth/users", async () => (await prisma.user.findMany({ orderBy: { createdAt: "asc" } })).map(view6));
23838
+ app2.get("/auth/users", async () => (await prisma.user.findMany({ orderBy: { createdAt: "asc" } })).map(view7));
23075
23839
  app2.post("/auth/users", async (req, reply) => {
23076
23840
  const p = zSignup.safeParse(req.body);
23077
23841
  if (!p.success) return reply.code(400).send({ error: p.error.flatten() });
@@ -23080,7 +23844,7 @@ async function auth_default(app2) {
23080
23844
  const user = await prisma.user.create({
23081
23845
  data: { email: p.data.email, passwordHash: await hashPassword(p.data.password) }
23082
23846
  });
23083
- return view6(user);
23847
+ return view7(user);
23084
23848
  });
23085
23849
  app2.delete("/auth/users/:id", async (req, reply) => {
23086
23850
  if (await prisma.user.count() <= 1) return reply.code(400).send({ error: "tak bisa hapus user terakhir" });
@@ -23100,7 +23864,7 @@ async function auth_default(app2) {
23100
23864
  });
23101
23865
  await deleteUserSessions(user.id);
23102
23866
  await issue(reply, user.id);
23103
- return { user: view6(user) };
23867
+ return { user: view7(user) };
23104
23868
  });
23105
23869
  }
23106
23870
 
@@ -23179,15 +23943,15 @@ async function agent_tokens_default(app2) {
23179
23943
  app2.post("/agent-tokens", async (req, reply) => {
23180
23944
  const p = zAgentTokenCreate.safeParse(req.body);
23181
23945
  if (!p.success) return reply.code(400).send({ error: p.error.flatten() });
23182
- const { view: view7, token } = await issueAgentToken({ ...p.data, createdBy: req.user?.id });
23183
- return reply.code(201).send({ ...view7, token });
23946
+ const { view: view8, token } = await issueAgentToken({ ...p.data, createdBy: req.user?.id });
23947
+ return reply.code(201).send({ ...view8, token });
23184
23948
  });
23185
23949
  app2.patch("/agent-tokens/:id", async (req, reply) => {
23186
23950
  const p = zAgentTokenPatch.safeParse(req.body);
23187
23951
  if (!p.success) return reply.code(400).send({ error: p.error.flatten() });
23188
23952
  const { id } = req.params;
23189
- const view7 = await patchAgentToken(id, p.data);
23190
- return view7 ? reply.send(view7) : reply.code(404).send({ error: "not found" });
23953
+ const view8 = await patchAgentToken(id, p.data);
23954
+ return view8 ? reply.send(view8) : reply.code(404).send({ error: "not found" });
23191
23955
  });
23192
23956
  app2.delete("/agent-tokens/:id", async (req, reply) => {
23193
23957
  const { id } = req.params;
@@ -23235,6 +23999,7 @@ function capabilityForRoute(method, path) {
23235
23999
  return read ? "GLOBAL_READ" : "COOKIE_ONLY";
23236
24000
  if (top === "scheduler") return rw("settings");
23237
24001
  if (top === "lead") return rw("lead");
24002
+ if (top === "custom-agents") return rw("agents");
23238
24003
  if (top === "settings" || top === "config") return rw("settings");
23239
24004
  if (top === "specs") return rw("backlog");
23240
24005
  if (top === "notifications") return rw("notifications");
@@ -23341,6 +24106,7 @@ function buildApp({ requireAuth = true } = {}) {
23341
24106
  await api.register(scheduler_default);
23342
24107
  await api.register(codex);
23343
24108
  await api.register(lead_default);
24109
+ await api.register(custom_agents_default);
23344
24110
  }, { prefix: "/api" });
23345
24111
  if (process.env.NODE_ENV === "production") {
23346
24112
  const dist = pickWebDir(dirname12(fileURLToPath4(import.meta.url)), process.env, existsSync11);
@@ -23407,6 +24173,11 @@ async function drain(cfg, deps) {
23407
24173
  await markDone(item.id, ALREADY_DONE_NOTE);
23408
24174
  continue;
23409
24175
  }
24176
+ const blocked = await deps.blockers(item.specId);
24177
+ if (blocked.length) {
24178
+ await noteQueued(item.id, blockedNote(blocked));
24179
+ continue;
24180
+ }
23410
24181
  const liveId = deps.isLive(item.specId);
23411
24182
  if (liveId) {
23412
24183
  await markLaunched(item.id, liveId);
@@ -23536,6 +24307,13 @@ var prodDeps = {
23536
24307
  const s = await prisma.spec.findUnique({ where: { id: specId }, select: { stage: true } });
23537
24308
  return s?.stage === "done";
23538
24309
  },
24310
+ // SPEC-447 · ADR-0093 · dibaca ULANG dari DB tepat sebelum launch, seperti `isDone`: `dependsOn`
24311
+ // bisa ditulis operator selagi item mengantre, dan merged-ness bergerak sendiri saat ada integrate.
24312
+ blockers: async (specId) => {
24313
+ const spec = await prisma.spec.findUnique({ where: { id: specId } });
24314
+ if (!spec) return [];
24315
+ return blockersForSpec(spec, await resolveRepoDir(spec.projectId));
24316
+ },
23539
24317
  launch: async (item, autonomy) => {
23540
24318
  const spec = await prisma.spec.findUnique({ where: { id: item.specId } });
23541
24319
  if (!spec) throw new Error(`spec ${item.specId} tak ada`);
@@ -23614,8 +24392,9 @@ async function shutdown(sig) {
23614
24392
  }
23615
24393
  process.on("SIGTERM", () => void shutdown("SIGTERM"));
23616
24394
  process.on("SIGINT", () => void shutdown("SIGINT"));
23617
- app.listen({ port, host }).then(() => {
24395
+ app.listen({ port, host }).then(async () => {
23618
24396
  console.log(`hanoman api ${host}:${port}`);
24397
+ await installCustomAgents();
23619
24398
  installSessionHistory();
23620
24399
  try {
23621
24400
  const liveIds = listSessions().map((s) => s.id);