hanoman 0.1.9 → 0.1.10

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
@@ -4606,6 +4606,47 @@ var init_custom_agent = __esm({
4606
4606
  }
4607
4607
  });
4608
4608
 
4609
+ // ../shared/src/github.ts
4610
+ function sourceForLabels(labels) {
4611
+ const hay = labels.map((l) => l.toLowerCase());
4612
+ for (const rule2 of LABEL_RULES)
4613
+ if (hay.some((l) => rule2.needles.some((n) => l.includes(n)))) return rule2.source;
4614
+ return "qa";
4615
+ }
4616
+ var zGithubIssueStatus, zNormalIssue, zGithubIssueView, LABEL_RULES;
4617
+ var init_github = __esm({
4618
+ "../shared/src/github.ts"() {
4619
+ "use strict";
4620
+ init_zod();
4621
+ zGithubIssueStatus = external_exports.enum(["new", "accepted", "rejected"]);
4622
+ zNormalIssue = external_exports.object({
4623
+ number: external_exports.number().int().positive(),
4624
+ title: external_exports.string(),
4625
+ body: external_exports.string(),
4626
+ authorLogin: external_exports.string(),
4627
+ labels: external_exports.array(external_exports.string()),
4628
+ url: external_exports.string(),
4629
+ issueState: external_exports.enum(["open", "closed"]),
4630
+ issueCreatedAt: external_exports.string(),
4631
+ // ISO-8601 apa adanya dari GitHub
4632
+ issueUpdatedAt: external_exports.string()
4633
+ });
4634
+ zGithubIssueView = zNormalIssue.extend({
4635
+ id: external_exports.string(),
4636
+ projectId: external_exports.string(),
4637
+ repoSlug: external_exports.string(),
4638
+ status: zGithubIssueStatus,
4639
+ specId: external_exports.string().nullable(),
4640
+ pulledAt: external_exports.string()
4641
+ });
4642
+ LABEL_RULES = [
4643
+ { needles: ["bug", "defect", "regression"], source: "qa" },
4644
+ { needles: ["question", "docs", "documentation"], source: "audit" },
4645
+ { needles: ["enhancement", "feature", "feat"], source: "brief" }
4646
+ ];
4647
+ }
4648
+ });
4649
+
4609
4650
  // ../shared/src/lead.ts
4610
4651
  function leadActionAllowed(action) {
4611
4652
  return LEAD_ACTIONS.includes(action);
@@ -5243,6 +5284,14 @@ var init_api = __esm({
5243
5284
  ticketUnlink: (id) => `${API}/tickets/${id}/unlink`,
5244
5285
  // SPEC-271 · lepas tautan backlog
5245
5286
  ticketReject: (id) => `${API}/tickets/${id}/reject`,
5287
+ // SPEC-471 · ADR-0095 · tarik & triase issue GitHub (di belakang gate cookie, capability `support`).
5288
+ // hanoman TIDAK PERNAH menulis ke GitHub — tak ada path komentar/close di sini, dan itu disengaja.
5289
+ githubPull: (id) => `${API}/projects/${encodeURIComponent(id)}/github/pull`,
5290
+ githubIssues: (id) => `${API}/projects/${encodeURIComponent(id)}/github/issues`,
5291
+ githubIssuesAccept: `${API}/github-issues/accept`,
5292
+ githubIssueAccept: (id) => `${API}/github-issues/${encodeURIComponent(id)}/accept`,
5293
+ githubIssueReject: (id) => `${API}/github-issues/${encodeURIComponent(id)}/reject`,
5294
+ githubIssueUnlink: (id) => `${API}/github-issues/${encodeURIComponent(id)}/unlink`,
5246
5295
  // SPEC-361 · ADR-0078 · unduh dokumen: query ditempelkan ke URL endpoint dokumen yang sudah ada
5247
5296
  // (tak ada endpoint ekspor terpisah). `base` bisa sudah membawa query, mis. ideFile(?path=…).
5248
5297
  download: (base, fmt) => `${base}${base.includes("?") ? "&" : "?"}download=${fmt}`
@@ -5450,6 +5499,28 @@ var init_config_registry = __esm({
5450
5499
  // vps
5451
5500
  { key: "HANOMAN_SSH_KEY_DIR", group: "vps", label: "Dir key SSH", kind: "path", apply: "new-session", category: "knob", help: "Default ~/.hanoman." },
5452
5501
  { key: "HANOMAN_SSH_BIN", group: "vps", label: "Biner ssh", kind: "path", apply: "new-session", category: "knob", default: "ssh" },
5502
+ // github (SPEC-471 · ADR-0095 · tarik issue → backlog). Dua mode auth, satu jalur kode:
5503
+ // `gh` memakai keyring mesin; bila GITHUB_TOKEN diisi ia diteruskan sebagai GH_TOKEN ke
5504
+ // proses gh DAN dipakai jalur REST (hub VPS yang tak punya keyring).
5505
+ {
5506
+ key: "GITHUB_TOKEN",
5507
+ group: "github",
5508
+ label: "GitHub token",
5509
+ kind: "secret",
5510
+ apply: "live",
5511
+ category: "credential",
5512
+ help: "PAT scope `repo` (atau `public_repo`). Kosong = andalkan `gh auth login` di mesin ini."
5513
+ },
5514
+ {
5515
+ key: "HANOMAN_GH_BIN",
5516
+ group: "github",
5517
+ label: "Biner gh",
5518
+ kind: "path",
5519
+ apply: "live",
5520
+ category: "knob",
5521
+ default: "gh",
5522
+ help: "Absen = tarik issue lewat HTTPS langsung ke api.github.com."
5523
+ },
5453
5524
  // runtime
5454
5525
  { key: "HANOMAN_EVENTS_TICK_MS", group: "runtime", label: "Interval events (ms)", kind: "int", apply: "live", category: "knob", default: "1000", min: 100 },
5455
5526
  { key: "HANOMAN_UPDATE_FETCH", group: "runtime", label: "Deteksi update saat boot", kind: "bool", apply: "live", category: "knob", default: "1" },
@@ -5542,6 +5613,7 @@ var init_src = __esm({
5542
5613
  init_entities();
5543
5614
  init_agent();
5544
5615
  init_custom_agent();
5616
+ init_github();
5545
5617
  init_lead();
5546
5618
  init_dto();
5547
5619
  init_api();
@@ -6582,7 +6654,7 @@ function readChoiceDialog(paneText2) {
6582
6654
  const found = [];
6583
6655
  for (const line of lines2.slice(0, footer)) {
6584
6656
  const m = ROW.exec(line);
6585
- if (m) found.push({ n: Number(m[1]), label: (m[2] ?? "").trim() });
6657
+ if (m) found.push({ n: Number(m[1]), label: cleanLabel(m[2] ?? "") });
6586
6658
  }
6587
6659
  if (found.length < 2) return null;
6588
6660
  const run2 = [];
@@ -6606,6 +6678,65 @@ function readChoiceDialog(paneText2) {
6606
6678
  options: rows.filter((r) => !r.free && !r.chat).map((r) => r.label)
6607
6679
  };
6608
6680
  }
6681
+ function readTabs(lines2) {
6682
+ for (let i = lines2.length - 1; i >= 0; i--) {
6683
+ const line = lines2[i] ?? "";
6684
+ if (!/[☐☒]/.test(line)) continue;
6685
+ const tabs = [];
6686
+ for (const tok of line.split(/\s{2,}/)) {
6687
+ const m = TAB_BOX.exec(tok.trim());
6688
+ if (m) tabs.push({ header: (m[2] ?? "").trim(), answered: m[1] === "\u2612" });
6689
+ }
6690
+ if (tabs.length) return { tabs, at: i };
6691
+ }
6692
+ return { tabs: [], at: -1 };
6693
+ }
6694
+ function readTitle(lines2, stripAt, footer) {
6695
+ if (stripAt < 0) return "";
6696
+ for (let i = stripAt + 1; i < footer; i++) {
6697
+ const line = (lines2[i] ?? "").trim();
6698
+ if (!line || RULE.test(line)) continue;
6699
+ return ROW.test(lines2[i] ?? "") ? "" : line;
6700
+ }
6701
+ return "";
6702
+ }
6703
+ function readReviewScreen(paneText2) {
6704
+ const lines2 = paneText2.split("\n").map((l) => l.trimEnd());
6705
+ const at = lastIndexOf(lines2, REVIEW_PROMPT);
6706
+ if (at < 0) return null;
6707
+ for (const line of lines2.slice(at)) {
6708
+ const m = SUBMIT_ROW.exec(line);
6709
+ if (m) return { submitRow: Number(m[1]) };
6710
+ }
6711
+ return null;
6712
+ }
6713
+ function readDialogScreen(paneText2) {
6714
+ const lines2 = paneText2.split("\n").map((l) => l.trimEnd());
6715
+ const footer = lastIndexOf(lines2, FOOTER);
6716
+ if (lastIndexOf(lines2, REVIEW_PROMPT) > footer) {
6717
+ const review = readReviewScreen(paneText2);
6718
+ if (review) return { kind: "review", submitRow: review.submitRow };
6719
+ }
6720
+ const d = readChoiceDialog(paneText2);
6721
+ if (!d) return null;
6722
+ const { tabs, at } = readTabs(lines2.slice(0, footer));
6723
+ return {
6724
+ kind: "question",
6725
+ rows: d.rows,
6726
+ freeIndex: d.freeIndex,
6727
+ options: d.options,
6728
+ notes: d.freeIndex === null && NOTES_FOOTER.test(lines2[footer] ?? ""),
6729
+ tabs,
6730
+ title: readTitle(lines2, at, footer)
6731
+ };
6732
+ }
6733
+ function dialogKey(paneText2) {
6734
+ const s = readDialogScreen(paneText2);
6735
+ if (!s) return "none";
6736
+ if (s.kind === "review") return "review";
6737
+ const tabs = s.tabs.map((t) => `${t.answered ? "x" : "o"}${t.header}`).join(",");
6738
+ return `q|${tabs}|${s.title || s.options.join("|")}`;
6739
+ }
6609
6740
  function freeTextFilled(paneText2, n) {
6610
6741
  const row = readChoiceDialog(paneText2)?.rows.find((r) => r.n === n);
6611
6742
  if (!row) return false;
@@ -6623,7 +6754,37 @@ async function answerChoiceDialog(io, freeIndex, line, chunkMs) {
6623
6754
  io.enter();
6624
6755
  return true;
6625
6756
  }
6626
- var FOOTER, ROW, PLACEHOLDER, CHAT_ROW, DIALOG_SETTLE_MS;
6757
+ function notesFilled(paneText2) {
6758
+ const lines2 = paneText2.split("\n").map((l) => l.trimEnd());
6759
+ for (let i = lines2.length - 1; i >= 0; i--) {
6760
+ const m = NOTES_LINE.exec(lines2[i] ?? "");
6761
+ if (!m) continue;
6762
+ const v = (m[1] ?? "").trim();
6763
+ return v.length > 0 && !NOTES_PLACEHOLDER.test(v);
6764
+ }
6765
+ return false;
6766
+ }
6767
+ async function answerNotesDialog(io, line, chunkMs) {
6768
+ io.literal("n");
6769
+ await io.sleep(DIALOG_SETTLE_MS);
6770
+ for (const chunk of goalChunks(line)) {
6771
+ io.literal(chunk);
6772
+ await io.sleep(chunkMs);
6773
+ }
6774
+ await io.sleep(DIALOG_SETTLE_MS);
6775
+ if (!notesFilled(io.capture())) return false;
6776
+ io.enter();
6777
+ return true;
6778
+ }
6779
+ async function submitReview(io, submitRow) {
6780
+ io.literal(String(submitRow));
6781
+ for (let i = 0; i < SUBMIT_TRIES; i++) {
6782
+ await io.sleep(DIALOG_SETTLE_MS);
6783
+ if (!readReviewScreen(io.capture())) return true;
6784
+ }
6785
+ return false;
6786
+ }
6787
+ 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;
6627
6788
  var init_tui_dialog = __esm({
6628
6789
  "src/services/tui-dialog.ts"() {
6629
6790
  "use strict";
@@ -6632,7 +6793,21 @@ var init_tui_dialog = __esm({
6632
6793
  ROW = /^\s*[❯>›]?\s*(\d{1,2})\.\s+(\S.*)$/;
6633
6794
  PLACEHOLDER = /^(?:type something\.?|other)$/i;
6634
6795
  CHAT_ROW = /^chat about this$/i;
6796
+ SIDE_PANEL = /\s{2,}[│┃┌┐└┘├┤╭╮╰╯─━].*$/;
6797
+ cleanLabel = (s) => s.replace(SIDE_PANEL, "").trim();
6798
+ REVIEW_PROMPT = /^\s*ready to submit your answers\?\s*$/i;
6799
+ SUBMIT_ROW = /^\s*[❯>›]?\s*(\d{1,2})\.\s+submit answers\s*$/i;
6800
+ NOTES_FOOTER = /\bn to add notes\b/i;
6801
+ TAB_BOX = /^([☐☒])\s*(.+)$/;
6802
+ lastIndexOf = (lines2, re) => {
6803
+ for (let i = lines2.length - 1; i >= 0; i--) if (re.test(lines2[i] ?? "")) return i;
6804
+ return -1;
6805
+ };
6806
+ RULE = /^[\s─━╌╍_=]*$/;
6635
6807
  DIALOG_SETTLE_MS = 250;
6808
+ NOTES_PLACEHOLDER = /^(?:press n to add notes|add notes on this design(?:…|\.\.\.)?)$/i;
6809
+ NOTES_LINE = /(?:^|\s)Notes:\s*(.*)$/;
6810
+ SUBMIT_TRIES = 8;
6636
6811
  }
6637
6812
  });
6638
6813
 
@@ -6921,18 +7096,13 @@ async function sendToPane(id, text, chunkMs = 50) {
6921
7096
  const line = text.replace(/\s*\r?\n\s*/g, " ").trim();
6922
7097
  if (!line) return false;
6923
7098
  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);
7099
+ const io = dialogIO(id);
7100
+ const screen = readDialogScreen(io.capture());
7101
+ if (screen?.kind === "review") return await submitReview(io, screen.submitRow);
7102
+ if (screen?.kind === "question") {
7103
+ if (screen.freeIndex !== null) return await answerChoiceDialog(io, screen.freeIndex, line, chunkMs);
7104
+ if (screen.notes) return await answerNotesDialog(io, line, chunkMs);
7105
+ }
6936
7106
  for (const chunk of goalChunks(line)) {
6937
7107
  io.literal(chunk);
6938
7108
  await sleep(chunkMs);
@@ -6943,6 +7113,18 @@ async function sendToPane(id, text, chunkMs = 50) {
6943
7113
  return false;
6944
7114
  }
6945
7115
  }
7116
+ async function submitPaneDialog(id) {
7117
+ const p = getSession(id);
7118
+ if (!p || p.exited) return false;
7119
+ try {
7120
+ const io = dialogIO(id);
7121
+ const screen = readDialogScreen(io.capture());
7122
+ if (screen?.kind !== "review") return false;
7123
+ return await submitReview(io, screen.submitRow);
7124
+ } catch {
7125
+ return false;
7126
+ }
7127
+ }
6946
7128
  async function armGoalInTui(id, condition, o = {}) {
6947
7129
  const pollMs = o.pollMs ?? 500, readyTries = o.readyTries ?? 20;
6948
7130
  const settleMs = o.settleMs ?? 1200, verifyTries = o.verifyTries ?? 12;
@@ -7117,7 +7299,7 @@ function spawnPty(...args) {
7117
7299
  );
7118
7300
  }
7119
7301
  }
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;
7302
+ 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, dialogIO, DIALOG_CAPTURE_LINES, GOAL_ARMED_MARKERS, goalArmed, phaseKey, paneComplete, sessionFinished, poll, detach;
7121
7303
  var init_pty = __esm({
7122
7304
  "src/services/pty.ts"() {
7123
7305
  "use strict";
@@ -7219,6 +7401,16 @@ var init_pty = __esm({
7219
7401
  return "";
7220
7402
  }
7221
7403
  };
7404
+ dialogIO = (id) => ({
7405
+ capture: () => capturePane(id, DIALOG_CAPTURE_LINES),
7406
+ literal: (s) => {
7407
+ tmux("send-keys", "-t", name(id), "-l", s);
7408
+ },
7409
+ enter: () => {
7410
+ tmux("send-keys", "-t", name(id), "Enter");
7411
+ },
7412
+ sleep
7413
+ });
7222
7414
  DIALOG_CAPTURE_LINES = 60;
7223
7415
  GOAL_ARMED_MARKERS = {
7224
7416
  claude: ["/goal"],
@@ -7321,6 +7513,8 @@ var init_rename_project = __esm({
7321
7513
  var sync_exports = {};
7322
7514
  __export(sync_exports, {
7323
7515
  SYNCED: () => SYNCED,
7516
+ __DATE_FIELDS: () => __DATE_FIELDS,
7517
+ __FIELDS: () => __FIELDS,
7324
7518
  __FIELDS_FOR_TEST: () => __FIELDS_FOR_TEST,
7325
7519
  applyPush: () => applyPush,
7326
7520
  backfillFeed: () => backfillFeed,
@@ -7459,13 +7653,13 @@ async function upsertLocal(entity, id, version, data) {
7459
7653
  function setAcceptedHook(hook) {
7460
7654
  onAccepted = hook;
7461
7655
  }
7462
- var SYNCED, DELEGATE, FIELDS, DATE_FIELDS, onAccepted, __FIELDS_FOR_TEST;
7656
+ var SYNCED, DELEGATE, FIELDS, DATE_FIELDS, __FIELDS, __DATE_FIELDS, onAccepted, __FIELDS_FOR_TEST;
7463
7657
  var init_sync = __esm({
7464
7658
  "src/services/sync.ts"() {
7465
7659
  "use strict";
7466
7660
  init_db();
7467
7661
  init_rename_project();
7468
- SYNCED = ["project", "spec", "vps", "sessionResult", "ticket", "ticketAttachment", "customAgent"];
7662
+ SYNCED = ["project", "spec", "vps", "sessionResult", "ticket", "ticketAttachment", "customAgent", "githubIssue"];
7469
7663
  DELEGATE = {
7470
7664
  project: prisma.project,
7471
7665
  spec: prisma.spec,
@@ -7473,7 +7667,8 @@ var init_sync = __esm({
7473
7667
  sessionResult: prisma.sessionResult,
7474
7668
  ticket: prisma.ticket,
7475
7669
  ticketAttachment: prisma.ticketAttachment,
7476
- customAgent: prisma.customAgent
7670
+ customAgent: prisma.customAgent,
7671
+ githubIssue: prisma.githubIssue
7477
7672
  };
7478
7673
  FIELDS = {
7479
7674
  project: ["name", "desc", "kind", "stack", "gitRemote", "updatedAt"],
@@ -7493,7 +7688,28 @@ var init_sync = __esm({
7493
7688
  // ada: `upsert` yang tak menyebut kolom ber-default TETAP berhasil, jadi kolom yang terlewat
7494
7689
  // mendarat sebagai default palsu di tiap client tanpa satu pun error (kelas ADR-0090/0093).
7495
7690
  // `version` tak pernah masuk FIELDS — ia stempel mekanisme sync itu sendiri.
7496
- customAgent: ["projectId", "name", "description", "instructions", "tools", "model", "mentions", "enabled", "createdAt", "updatedAt"]
7691
+ customAgent: ["projectId", "name", "description", "instructions", "tools", "model", "mentions", "enabled", "createdAt", "updatedAt"],
7692
+ // SPEC-471 · ADR-0095 · SELURUH kolom bermakna ikut. `status`/`specId` termasuk: keputusan
7693
+ // triase adalah bagian keadaan yang harus dilihat sama oleh semua mesin — tanpa itu satu
7694
+ // mesin bisa menerima ulang issue yang di mesin lain sudah jadi backlog.
7695
+ githubIssue: [
7696
+ "projectId",
7697
+ "repoSlug",
7698
+ "number",
7699
+ "title",
7700
+ "body",
7701
+ "authorLogin",
7702
+ "labels",
7703
+ "url",
7704
+ "issueState",
7705
+ "status",
7706
+ "specId",
7707
+ "issueCreatedAt",
7708
+ "issueUpdatedAt",
7709
+ "pulledAt",
7710
+ "createdAt",
7711
+ "updatedAt"
7712
+ ]
7497
7713
  };
7498
7714
  DATE_FIELDS = {
7499
7715
  project: ["updatedAt"],
@@ -7502,8 +7718,11 @@ var init_sync = __esm({
7502
7718
  sessionResult: ["createdAt", "updatedAt"],
7503
7719
  ticket: ["createdAt", "updatedAt"],
7504
7720
  ticketAttachment: ["createdAt", "updatedAt"],
7505
- customAgent: ["createdAt", "updatedAt"]
7721
+ customAgent: ["createdAt", "updatedAt"],
7722
+ githubIssue: ["issueCreatedAt", "issueUpdatedAt", "pulledAt", "createdAt", "updatedAt"]
7506
7723
  };
7724
+ __FIELDS = FIELDS;
7725
+ __DATE_FIELDS = DATE_FIELDS;
7507
7726
  __FIELDS_FOR_TEST = FIELDS;
7508
7727
  }
7509
7728
  });
@@ -9670,7 +9889,7 @@ var require_extension = __commonJS({
9670
9889
  if (dest[name2] === void 0) dest[name2] = [elem];
9671
9890
  else dest[name2].push(elem);
9672
9891
  }
9673
- function parse2(header) {
9892
+ function parse3(header) {
9674
9893
  const offers = /* @__PURE__ */ Object.create(null);
9675
9894
  let params = /* @__PURE__ */ Object.create(null);
9676
9895
  let mustUnescape = false;
@@ -9810,7 +10029,7 @@ var require_extension = __commonJS({
9810
10029
  }).join(", ");
9811
10030
  }).join(", ");
9812
10031
  }
9813
- module.exports = { format, parse: parse2 };
10032
+ module.exports = { format, parse: parse3 };
9814
10033
  }
9815
10034
  });
9816
10035
 
@@ -9844,7 +10063,7 @@ var require_websocket = __commonJS({
9844
10063
  var {
9845
10064
  EventTarget: { addEventListener, removeEventListener }
9846
10065
  } = require_event_target();
9847
- var { format, parse: parse2 } = require_extension();
10066
+ var { format, parse: parse3 } = require_extension();
9848
10067
  var { toBuffer } = require_buffer_util();
9849
10068
  var kAborted = Symbol("kAborted");
9850
10069
  var protocolVersions = [8, 13];
@@ -10521,7 +10740,7 @@ var require_websocket = __commonJS({
10521
10740
  }
10522
10741
  let extensions;
10523
10742
  try {
10524
- extensions = parse2(secWebSocketExtensions);
10743
+ extensions = parse3(secWebSocketExtensions);
10525
10744
  } catch (err) {
10526
10745
  const message = "Invalid Sec-WebSocket-Extensions header";
10527
10746
  abortHandshake(websocket2, socket2, message);
@@ -10813,7 +11032,7 @@ var require_subprotocol = __commonJS({
10813
11032
  "../node_modules/.pnpm/ws@8.21.0/node_modules/ws/lib/subprotocol.js"(exports, module) {
10814
11033
  "use strict";
10815
11034
  var { tokenChars } = require_validation();
10816
- function parse2(header) {
11035
+ function parse3(header) {
10817
11036
  const protocols = /* @__PURE__ */ new Set();
10818
11037
  let start = -1;
10819
11038
  let end2 = -1;
@@ -10849,7 +11068,7 @@ var require_subprotocol = __commonJS({
10849
11068
  protocols.add(protocol);
10850
11069
  return protocols;
10851
11070
  }
10852
- module.exports = { parse: parse2 };
11071
+ module.exports = { parse: parse3 };
10853
11072
  }
10854
11073
  });
10855
11074
 
@@ -14280,7 +14499,7 @@ var require_secure_json_parse = __commonJS({
14280
14499
  }
14281
14500
  return obj;
14282
14501
  }
14283
- function parse2(text, reviver, options2) {
14502
+ function parse3(text, reviver, options2) {
14284
14503
  const stackTraceLimit = Error.stackTraceLimit;
14285
14504
  Error.stackTraceLimit = 0;
14286
14505
  try {
@@ -14300,9 +14519,9 @@ var require_secure_json_parse = __commonJS({
14300
14519
  Error.stackTraceLimit = stackTraceLimit;
14301
14520
  }
14302
14521
  }
14303
- module.exports = parse2;
14304
- module.exports.default = parse2;
14305
- module.exports.parse = parse2;
14522
+ module.exports = parse3;
14523
+ module.exports.default = parse3;
14524
+ module.exports.parse = parse3;
14306
14525
  module.exports.safeParse = safeParse;
14307
14526
  module.exports.scan = filter;
14308
14527
  }
@@ -21665,11 +21884,11 @@ function clamp(text) {
21665
21884
  const buf = Buffer.from(text, "utf8");
21666
21885
  if (buf.byteLength <= MAX_TRANSCRIPT_BYTES) return { body: text, truncated: false };
21667
21886
  const cut = buf.byteLength - MAX_TRANSCRIPT_BYTES;
21668
- let tail2 = buf.subarray(cut).toString("utf8");
21669
- const nl = tail2.indexOf("\n");
21670
- if (nl >= 0) tail2 = tail2.slice(nl + 1);
21887
+ let tail3 = buf.subarray(cut).toString("utf8");
21888
+ const nl = tail3.indexOf("\n");
21889
+ if (nl >= 0) tail3 = tail3.slice(nl + 1);
21671
21890
  return { body: `\u2026 ${cut} byte awal dipangkas (batas ${MAX_TRANSCRIPT_BYTES} byte) \u2026
21672
- ${tail2}`, truncated: true };
21891
+ ${tail3}`, truncated: true };
21673
21892
  }
21674
21893
  async function saveTranscript(text) {
21675
21894
  if (!text.trim()) return { key: "", bytes: 0, truncated: false };
@@ -22629,6 +22848,18 @@ function leadArgv(o) {
22629
22848
  ];
22630
22849
  }
22631
22850
  var leadEnv = (agent, base = process.env, uid = process.getuid?.()) => agent === "claude" ? { ...rootBypassEnv(uid), ...base } : { ...base };
22851
+ var EXPLAIN_MAX = 500;
22852
+ var tail = (s) => {
22853
+ const t = s.trim();
22854
+ return t.length > EXPLAIN_MAX ? `\u2026${t.slice(-EXPLAIN_MAX)}` : t;
22855
+ };
22856
+ function leadFailureReason(agent, timeoutMs, err, stdout, stderr) {
22857
+ if (err.killed) return `lead ${agent} kehabisan waktu ${timeoutMs} ms`;
22858
+ const fromErr = err.message.startsWith("Command failed:") ? "" : err.message;
22859
+ const detail = [tail(stderr), tail(stdout)].filter(Boolean).join(" \xB7 ") || tail(fromErr) || "tanpa keluaran";
22860
+ const how = err.signal ? `sinyal ${err.signal}` : `exit ${err.code ?? "?"}`;
22861
+ return `lead ${agent} gagal (${how}): ${detail}`;
22862
+ }
22632
22863
  function think(prompt, o) {
22633
22864
  const bin = binFor(o.agent);
22634
22865
  const args = leadArgv({ agent: o.agent, model: o.model, effort: o.effort, prompt });
@@ -22642,8 +22873,7 @@ function think(prompt, o) {
22642
22873
  killSignal: "SIGTERM"
22643
22874
  }, (err, stdout, stderr) => {
22644
22875
  if (err) {
22645
- const killed = err.killed;
22646
- reject(new Error(killed ? `lead ${o.agent} kehabisan waktu ${o.timeoutMs} ms` : `lead ${o.agent} gagal: ${(stderr || err.message).trim().slice(0, 500)}`));
22876
+ reject(new Error(leadFailureReason(o.agent, o.timeoutMs, err, stdout, stderr)));
22647
22877
  return;
22648
22878
  }
22649
22879
  resolve14(stdout);
@@ -22921,12 +23151,13 @@ Bukti integrasi: ${evidence.join("; ")} \u2192 ${verdict}.` }
22921
23151
 
22922
23152
  // src/services/lead/detect.ts
22923
23153
  init_pty();
23154
+ init_tui_dialog();
22924
23155
  import { writeFileSync as writeFileSync4 } from "node:fs";
22925
23156
 
22926
23157
  // src/services/lead/pane.ts
22927
23158
  init_tui_dialog();
22928
23159
  var CLEAN = /[│┃┆┊┌┐└┘├┤┬┴┼─━╭╮╰╯>❯]/g;
22929
- var tail = (text, lines2) => text.split("\n").map((l) => l.replace(CLEAN, " ").trimEnd()).filter((l) => l.trim()).slice(-lines2);
23160
+ var tail2 = (text, lines2) => text.split("\n").map((l) => l.replace(CLEAN, " ").trimEnd()).filter((l) => l.trim()).slice(-lines2);
22930
23161
  var CODEX_FINISHED = [
22931
23162
  /Goal achieved/i,
22932
23163
  /Goal unmet/i,
@@ -22939,11 +23170,11 @@ var ASK_SIGNALS = [
22939
23170
  /\b(pilih|apakah|haruskah|mana yang|opsi|which|should i|do you want|proceed\?)\b/i
22940
23171
  ];
22941
23172
  function readPaneQuestion(text, agent) {
22942
- const lines2 = tail(text, 40);
23173
+ const lines2 = tail2(text, 40);
22943
23174
  const body = lines2.join("\n").trim();
22944
23175
  const choices = readChoiceDialog(text)?.options ?? [];
22945
23176
  if (!body) return { asking: false, question: "", reason: "layar kosong", choices };
22946
- const question = tail(text, 25).join("\n").trim();
23177
+ const question = tail2(text, 25).join("\n").trim();
22947
23178
  if (agent === "codex") {
22948
23179
  const finished = CODEX_FINISHED.find((re) => re.test(body));
22949
23180
  if (finished) return { asking: false, question, reason: "sesi codex selesai wajar (ADR-0074)", choices };
@@ -22956,9 +23187,16 @@ function readPaneQuestion(text, agent) {
22956
23187
  // src/services/lead/detect.ts
22957
23188
  var answers = /* @__PURE__ */ new Map();
22958
23189
  var capped = /* @__PURE__ */ new Set();
23190
+ var failures = /* @__PURE__ */ new Map();
23191
+ var failCapped = /* @__PURE__ */ new Set();
23192
+ var MAX_CHAIN_STEPS = 6;
23193
+ var CHAIN_POLL_MS = 300;
23194
+ var CHAIN_POLL_TRIES = 20;
22959
23195
  function resetSession(sessionId2) {
22960
23196
  answers.delete(sessionId2);
22961
23197
  capped.delete(sessionId2);
23198
+ failures.delete(sessionId2);
23199
+ failCapped.delete(sessionId2);
22962
23200
  }
22963
23201
  var prodDetectDeps = {
22964
23202
  live: () => {
@@ -22994,6 +23232,8 @@ var prodDetectDeps = {
22994
23232
  } catch {
22995
23233
  }
22996
23234
  },
23235
+ submit: (id) => submitPaneDialog(id),
23236
+ sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
22997
23237
  decide,
22998
23238
  decideDeps: prodDecideDeps,
22999
23239
  optIn: leadProjects,
@@ -23025,7 +23265,7 @@ async function scanAndAnswer(deps = prodDetectDeps) {
23025
23265
  if ((answers.get(s.id) ?? 0) >= cfg.maxAutoAnswers) {
23026
23266
  if (!capped.has(s.id)) {
23027
23267
  capped.add(s.id);
23028
- const row2 = await recordDecision({
23268
+ const row = await recordDecision({
23029
23269
  projectId: s.projectId,
23030
23270
  specId: s.specId,
23031
23271
  sessionId: s.id,
@@ -23040,7 +23280,7 @@ async function scanAndAnswer(deps = prodDetectDeps) {
23040
23280
  weighty: true
23041
23281
  });
23042
23282
  await deps.notify(
23043
- row2.id,
23283
+ row.id,
23044
23284
  `Lead berhenti menjawab sesi ${s.id} (batas ${cfg.maxAutoAnswers}\xD7 tercapai)`,
23045
23285
  s.projectId,
23046
23286
  s.specId ?? null,
@@ -23050,16 +23290,79 @@ async function scanAndAnswer(deps = prodDetectDeps) {
23050
23290
  skip("batas jawaban otomatis tercapai");
23051
23291
  continue;
23052
23292
  }
23293
+ if ((failures.get(s.id) ?? 0) >= cfg.maxAutoAnswers) {
23294
+ if (!failCapped.has(s.id)) {
23295
+ failCapped.add(s.id);
23296
+ const row = await recordDecision({
23297
+ projectId: s.projectId,
23298
+ specId: s.specId,
23299
+ sessionId: s.id,
23300
+ gate: "detected",
23301
+ kind: "quality",
23302
+ question: `Keputusan untuk sesi ${s.id} gagal disusun ${cfg.maxAutoAnswers}\xD7 berturut-turut.`,
23303
+ answer: "Berhenti mencoba; serahkan ke operator.",
23304
+ reason: "Kegagalan beruntun menandakan sebab yang tak hilang dengan mengulang (kunci/kuota agen, biner tak terpasang) \u2014 mencoba lagi tiap denyut hanya membakar kuota. Alasan percobaan terakhir ada di baris jejak `gagal` tepat di atas ini.",
23305
+ refs: [],
23306
+ confidence: "tinggi",
23307
+ action: "none",
23308
+ weighty: true
23309
+ });
23310
+ await deps.notify(
23311
+ row.id,
23312
+ `Lead berhenti mencoba sesi ${s.id} (${cfg.maxAutoAnswers}\xD7 gagal berturut-turut)`,
23313
+ s.projectId,
23314
+ s.specId ?? null,
23315
+ s.id
23316
+ );
23317
+ }
23318
+ skip("batas kegagalan beruntun tercapai");
23319
+ continue;
23320
+ }
23053
23321
  const agent = deps.agentOf(s.id) ?? "claude";
23054
23322
  const read = readPaneQuestion(deps.pane(s.id), agent);
23055
23323
  if (!read.asking) {
23056
23324
  skip(read.reason);
23057
23325
  continue;
23058
23326
  }
23327
+ const chain = await runChain(s, agent, deps);
23328
+ if (chain.acted && chain.done) {
23329
+ deps.clearMarker(s.decisionFile);
23330
+ answers.set(s.id, (answers.get(s.id) ?? 0) + 1);
23331
+ failures.delete(s.id);
23332
+ out3.answered.push(s.id);
23333
+ continue;
23334
+ }
23335
+ if (chain.failed) failures.set(s.id, (failures.get(s.id) ?? 0) + 1);
23336
+ skip(chain.reason);
23337
+ }
23338
+ return out3;
23339
+ }
23340
+ async function runChain(s, agent, deps) {
23341
+ let acted = false;
23342
+ for (let step = 0; step < MAX_CHAIN_STEPS; step++) {
23343
+ const text = deps.pane(s.id);
23344
+ const screen = readDialogScreen(text);
23345
+ if (screen?.kind === "review") {
23346
+ if (!await deps.submit(s.id))
23347
+ return { acted, done: false, failed: true, reason: "gagal menekan Submit answers" };
23348
+ acted = true;
23349
+ continue;
23350
+ }
23351
+ if (step > 0 && !screen) return { acted, done: true, failed: false, reason: "" };
23352
+ const read = readPaneQuestion(text, agent);
23353
+ if (!read.asking) {
23354
+ return step === 0 ? { acted, done: false, failed: false, reason: read.reason } : { acted, done: true, failed: false, reason: "" };
23355
+ }
23059
23356
  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
23357
  if (read.choices.length) {
23061
23358
  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
23359
  }
23360
+ if (screen?.kind === "question" && screen.tabs.length > 1) {
23361
+ const at = screen.tabs.findIndex((t) => !t.answered);
23362
+ notes.push(
23363
+ `Dialog ini BERANTAI: ${screen.tabs.length} pertanyaan dalam satu tanya (${screen.tabs.map((t) => `${t.answered ? "sudah" : "belum"}: ${t.header}`).join(", ")}). Yang sedang tampil pertanyaan ke-${at + 1}; jawab HANYA pertanyaan itu \u2014 sisanya akan ditanyakan sesudah ini.`
23364
+ );
23365
+ }
23063
23366
  const row = await deps.decide({
23064
23367
  projectId: s.projectId,
23065
23368
  specId: s.specId,
@@ -23070,26 +23373,32 @@ async function scanAndAnswer(deps = prodDetectDeps) {
23070
23373
  options: read.choices.length ? read.choices : void 0,
23071
23374
  notes
23072
23375
  }, deps.decideDeps);
23073
- if (!row || row.status !== "berlaku") {
23074
- skip("lead tak menghasilkan keputusan yang berlaku");
23075
- continue;
23076
- }
23376
+ if (!row) return { acted, done: false, failed: false, reason: "lead tak menghasilkan keputusan yang berlaku" };
23377
+ if (row.status !== "berlaku")
23378
+ return { acted, done: false, failed: true, reason: "lead tak menghasilkan keputusan yang berlaku" };
23077
23379
  const reply = takeReply(row.id) || row.answer;
23078
- const sent = await deps.send(s.id, reply);
23079
- if (!sent) {
23080
- skip("gagal mengetik ke pane");
23081
- continue;
23082
- }
23083
- deps.clearMarker(s.decisionFile);
23084
- answers.set(s.id, (answers.get(s.id) ?? 0) + 1);
23085
- out3.answered.push(s.id);
23380
+ if (!await deps.send(s.id, reply))
23381
+ return { acted, done: false, failed: true, reason: "gagal mengetik ke pane" };
23382
+ acted = true;
23383
+ if (!screen) return { acted, done: true, failed: false, reason: "" };
23384
+ if (!await waitScreenChange(s.id, dialogKey(text), deps))
23385
+ return { acted, done: false, failed: true, reason: "layar dialog tak berubah sesudah dijawab" };
23086
23386
  }
23087
- return out3;
23387
+ return { acted, done: false, failed: true, reason: "batas langkah rantai dialog tercapai" };
23388
+ }
23389
+ async function waitScreenChange(id, before, deps) {
23390
+ for (let i = 0; i < CHAIN_POLL_TRIES; i++) {
23391
+ await deps.sleep(CHAIN_POLL_MS);
23392
+ if (dialogKey(deps.pane(id)) !== before) return true;
23393
+ }
23394
+ return false;
23088
23395
  }
23089
23396
  function sweep(liveIds) {
23090
23397
  const live = new Set(liveIds);
23091
23398
  for (const id of [...answers.keys()]) if (!live.has(id)) answers.delete(id);
23092
23399
  for (const id of [...capped]) if (!live.has(id)) capped.delete(id);
23400
+ for (const id of [...failures.keys()]) if (!live.has(id)) failures.delete(id);
23401
+ for (const id of [...failCapped]) if (!live.has(id)) failCapped.delete(id);
23093
23402
  }
23094
23403
 
23095
23404
  // src/services/lead/pulse.ts
@@ -23715,6 +24024,414 @@ async function custom_agents_default(app2) {
23715
24024
  });
23716
24025
  }
23717
24026
 
24027
+ // src/routes/github-issues.ts
24028
+ init_zod();
24029
+ init_db();
24030
+
24031
+ // src/services/github-issues.ts
24032
+ init_db();
24033
+
24034
+ // src/services/github-repo.ts
24035
+ init_db();
24036
+ function parse2(url2) {
24037
+ const u = url2.trim();
24038
+ let m = /^git@([^:]+):([^/]+)\/(.+?)(?:\.git)?$/.exec(u);
24039
+ if (!m) m = /^https?:\/\/(?:[^@/]+@)?([^/]+)\/([^/]+)\/(.+?)(?:\.git)?\/?$/.exec(u);
24040
+ if (!m || !m[1] || !m[2] || !m[3]) return null;
24041
+ return { host: m[1], owner: m[2], repo: m[3] };
24042
+ }
24043
+ function githubSlugFromUrl(url2) {
24044
+ const p = parse2(url2);
24045
+ if (!p || !p.host.includes("github.")) return null;
24046
+ return { owner: p.owner, repo: p.repo, slug: `${p.owner}/${p.repo}` };
24047
+ }
24048
+ async function resolveGithubRepo(projectId) {
24049
+ const project = await prisma.project.findUnique({ where: { id: projectId } });
24050
+ if (!project) return { ok: false, kind: "no-project", error: `project "${projectId}" tidak ada` };
24051
+ const candidates = [];
24052
+ if (project.gitRemote) candidates.push(project.gitRemote);
24053
+ const repoDir = await resolveRepoDir(projectId).catch(() => null);
24054
+ if (repoDir) {
24055
+ const origin = (await listRemotes(repoDir)).find((r) => r.name === "origin");
24056
+ if (origin?.fetch) candidates.push(origin.fetch);
24057
+ }
24058
+ if (candidates.length === 0)
24059
+ return {
24060
+ ok: false,
24061
+ kind: "no-remote",
24062
+ error: "project belum punya remote GitHub (isi gitRemote atau tambahkan origin di repo lokalnya)"
24063
+ };
24064
+ for (const url2 of candidates) {
24065
+ const gh = githubSlugFromUrl(url2);
24066
+ if (gh) return { ok: true, repo: gh };
24067
+ }
24068
+ const host2 = parse2(candidates[0])?.host ?? candidates[0];
24069
+ return {
24070
+ ok: false,
24071
+ kind: "not-github",
24072
+ error: `remote project ber-host "${host2}", bukan GitHub \u2014 tarik issue hanya mendukung GitHub`
24073
+ };
24074
+ }
24075
+
24076
+ // src/services/github-fetch.ts
24077
+ init_config2();
24078
+ import { execFile as execFile10 } from "node:child_process";
24079
+ var labelNames = (l) => (l ?? []).map((x) => typeof x === "string" ? x : x.name ?? "").filter(Boolean);
24080
+ var norm = (s) => s.toLowerCase() === "closed" ? "closed" : "open";
24081
+ function issueFromGh(raw) {
24082
+ return {
24083
+ number: raw.number,
24084
+ title: raw.title,
24085
+ body: raw.body ?? "",
24086
+ authorLogin: raw.author?.login ?? "",
24087
+ labels: labelNames(raw.labels),
24088
+ url: raw.url,
24089
+ issueState: norm(raw.state),
24090
+ issueCreatedAt: raw.createdAt,
24091
+ issueUpdatedAt: raw.updatedAt
24092
+ };
24093
+ }
24094
+ function issuesFromRest(raw) {
24095
+ let skippedPullRequests = 0;
24096
+ const issues = [];
24097
+ for (const r of raw) {
24098
+ if (r.pull_request !== void 0) {
24099
+ skippedPullRequests++;
24100
+ continue;
24101
+ }
24102
+ issues.push({
24103
+ number: r.number,
24104
+ title: r.title,
24105
+ body: r.body ?? "",
24106
+ authorLogin: r.user?.login ?? "",
24107
+ labels: labelNames(r.labels),
24108
+ url: r.html_url,
24109
+ issueState: norm(r.state),
24110
+ issueCreatedAt: r.created_at,
24111
+ issueUpdatedAt: r.updated_at
24112
+ });
24113
+ }
24114
+ return { issues, skippedPullRequests };
24115
+ }
24116
+ var GH_FIELDS = "number,title,body,author,labels,url,state,createdAt,updatedAt";
24117
+ var API2 = "https://api.github.com";
24118
+ var defaultRunGh = (args, env) => new Promise((resolve14, reject) => {
24119
+ execFile10(
24120
+ args[0],
24121
+ args.slice(1),
24122
+ { env, maxBuffer: 1 << 26, encoding: "utf8", timeout: 6e4 },
24123
+ (err, stdout, stderr) => {
24124
+ const e = err;
24125
+ if (e && (e.code === "ENOENT" || e.code === "EACCES")) return reject(e);
24126
+ resolve14({ code: err ? Number(err.code ?? 1) : 0, stdout, stderr });
24127
+ }
24128
+ );
24129
+ });
24130
+ var defaultHttpGet = async (url2, headers) => {
24131
+ const res = await fetch(url2, {
24132
+ headers: { Accept: "application/vnd.github+json", "User-Agent": "hanoman", ...headers }
24133
+ });
24134
+ const json = await res.json().catch(() => null);
24135
+ return { status: res.status, json };
24136
+ };
24137
+ function classifyGhStderr(stderr) {
24138
+ const s = stderr.toLowerCase();
24139
+ if (s.includes("gh auth login") || s.includes("bad credentials") || s.includes("http 401")) return "unauth";
24140
+ if (s.includes("disabled issues")) return "issues-disabled";
24141
+ if (s.includes("could not resolve to a repository") || s.includes("http 404")) return "not-found";
24142
+ return "other";
24143
+ }
24144
+ async function viaGh(repo, opts, deps) {
24145
+ const bin = deps.ghBin ?? effectiveStr("HANOMAN_GH_BIN") ?? "gh";
24146
+ const args = [
24147
+ bin,
24148
+ "issue",
24149
+ "list",
24150
+ "--repo",
24151
+ repo.slug,
24152
+ "--state",
24153
+ opts.state,
24154
+ "--limit",
24155
+ String(opts.limit),
24156
+ "--json",
24157
+ GH_FIELDS
24158
+ ];
24159
+ const env = { ...process.env };
24160
+ if (deps.token) env.GH_TOKEN = deps.token;
24161
+ let out3;
24162
+ try {
24163
+ out3 = await (deps.runGh ?? defaultRunGh)(args, env);
24164
+ } catch {
24165
+ return { fallback: true, reason: "gh tak terpasang" };
24166
+ }
24167
+ if (out3.code !== 0) {
24168
+ const kind = classifyGhStderr(out3.stderr);
24169
+ if (kind === "unauth") return { fallback: true, reason: "gh tak terautentikasi" };
24170
+ return { ok: false, kind, error: out3.stderr.trim() || `gh keluar dengan kode ${out3.code}` };
24171
+ }
24172
+ let raw;
24173
+ try {
24174
+ raw = JSON.parse(out3.stdout || "[]");
24175
+ } catch {
24176
+ return { ok: false, kind: "other", error: "keluaran gh bukan JSON" };
24177
+ }
24178
+ return { ok: true, issues: raw.map(issueFromGh), via: "gh", skippedPullRequests: 0 };
24179
+ }
24180
+ async function viaRest(repo, opts, deps) {
24181
+ const get = deps.httpGet ?? defaultHttpGet;
24182
+ const headers = {};
24183
+ if (deps.token) headers.Authorization = `Bearer ${deps.token}`;
24184
+ const meta = await get(`${API2}/repos/${repo.slug}`, headers);
24185
+ if (meta.status === 404)
24186
+ return { ok: false, kind: "not-found", error: `repo "${repo.slug}" tak ditemukan atau tak terjangkau` };
24187
+ if (meta.status === 401 || meta.status === 403)
24188
+ return { ok: false, kind: "unauthorized", error: "GitHub menolak kredensial \u2014 isi GITHUB_TOKEN di Settings" };
24189
+ if (meta.status !== 200) return { ok: false, kind: "other", error: `GitHub menjawab HTTP ${meta.status}` };
24190
+ if (meta.json?.has_issues === false)
24191
+ return { ok: false, kind: "issues-disabled", error: `repo "${repo.slug}" mematikan fitur issue` };
24192
+ const issues = [];
24193
+ let skippedPullRequests = 0;
24194
+ for (let page = 1; issues.length < opts.limit && page <= 10; page++) {
24195
+ const per = Math.min(100, opts.limit - issues.length);
24196
+ const res = await get(
24197
+ `${API2}/repos/${repo.slug}/issues?state=${opts.state}&per_page=${per}&page=${page}`,
24198
+ headers
24199
+ );
24200
+ if (res.status === 404) return { ok: false, kind: "not-found", error: `repo "${repo.slug}" tak ditemukan` };
24201
+ if (res.status === 401 || res.status === 403)
24202
+ return { ok: false, kind: "unauthorized", error: "GitHub menolak kredensial" };
24203
+ if (res.status !== 200) return { ok: false, kind: "other", error: `GitHub menjawab HTTP ${res.status}` };
24204
+ const batch = Array.isArray(res.json) ? res.json : [];
24205
+ if (batch.length === 0) break;
24206
+ const n = issuesFromRest(batch);
24207
+ issues.push(...n.issues);
24208
+ skippedPullRequests += n.skippedPullRequests;
24209
+ if (batch.length < per) break;
24210
+ }
24211
+ return { ok: true, issues: issues.slice(0, opts.limit), via: "rest", skippedPullRequests };
24212
+ }
24213
+ async function fetchIssues(repo, opts, deps = {}) {
24214
+ const token = deps.token ?? effectiveStr("GITHUB_TOKEN") ?? void 0;
24215
+ const first = await viaGh(repo, opts, { ...deps, token });
24216
+ if (!("fallback" in first)) return first;
24217
+ return viaRest(repo, opts, { ...deps, token });
24218
+ }
24219
+
24220
+ // src/services/github-issues.ts
24221
+ var issueRowId = (projectId, slug, number) => `${projectId}:${slug}#${number}`;
24222
+ async function pullIssues(projectId, opts = {}, deps = {}) {
24223
+ const resolved = await resolveGithubRepo(projectId);
24224
+ if (!resolved.ok) return { ok: false, kind: resolved.kind, error: resolved.error };
24225
+ const state = opts.state ?? "open";
24226
+ const limit = Math.min(Math.max(opts.limit ?? 200, 1), 1e3);
24227
+ const got = await fetchIssues(resolved.repo, { state, limit }, deps);
24228
+ if (!got.ok) return { ok: false, kind: got.kind, error: got.error };
24229
+ const slug = resolved.repo.slug;
24230
+ const now = /* @__PURE__ */ new Date();
24231
+ let created = 0, updated = 0;
24232
+ for (const i of got.issues) {
24233
+ const id = issueRowId(projectId, slug, i.number);
24234
+ const exists = await prisma.githubIssue.findUnique({ where: { id }, select: { id: true } });
24235
+ const fresh = {
24236
+ title: i.title,
24237
+ body: i.body,
24238
+ authorLogin: i.authorLogin,
24239
+ labels: i.labels,
24240
+ url: i.url,
24241
+ issueState: i.issueState,
24242
+ issueCreatedAt: new Date(i.issueCreatedAt),
24243
+ issueUpdatedAt: new Date(i.issueUpdatedAt),
24244
+ pulledAt: now
24245
+ };
24246
+ await prisma.githubIssue.upsert({
24247
+ where: { id },
24248
+ create: { id, projectId, repoSlug: slug, number: i.number, status: "new", specId: null, ...fresh },
24249
+ update: fresh
24250
+ });
24251
+ if (exists) updated++;
24252
+ else created++;
24253
+ await notifySynced("githubIssue", id);
24254
+ }
24255
+ return {
24256
+ ok: true,
24257
+ repo: slug,
24258
+ pulled: got.issues.length,
24259
+ created,
24260
+ updated,
24261
+ via: got.via,
24262
+ skippedPullRequests: got.skippedPullRequests
24263
+ };
24264
+ }
24265
+
24266
+ // src/services/github-accept.ts
24267
+ init_src();
24268
+ init_db();
24269
+ var backlinkOf = (i) => `Dari GitHub issue ${i.repoSlug}#${i.number} (${i.url}).`;
24270
+ async function acceptGithubIssue(issue2, opts) {
24271
+ if (issue2.specId) {
24272
+ const spec2 = await prisma.spec.findUnique({ where: { id: issue2.specId } });
24273
+ if (spec2) return { spec: spec2, created: false };
24274
+ }
24275
+ const labels = Array.isArray(issue2.labels) ? issue2.labels : [];
24276
+ const source = opts.source ?? sourceForLabels(labels);
24277
+ const priority = opts.priority ?? "sedang";
24278
+ const backlink = backlinkOf(issue2);
24279
+ const detail = `${issue2.body}
24280
+
24281
+ Pelapor: @${issue2.authorLogin}
24282
+ Label: ${labels.length ? labels.join(", ") : "(tanpa label)"}
24283
+ ${backlink}`;
24284
+ const payload = source === "qa" ? {
24285
+ severity: "major",
24286
+ steps: "Reproduksi dari deskripsi issue.",
24287
+ expected: "Perilaku yang diharapkan pelapor issue.",
24288
+ actual: detail,
24289
+ env: ""
24290
+ } : { context: detail, outcome: "", constraints: "", priority };
24291
+ const repoDir = await resolveRepoDir(issue2.projectId).catch(() => null);
24292
+ let spec = null;
24293
+ for (let attempt = 0; attempt < 3 && !spec; attempt++) {
24294
+ const sid = await nextSpecId(repoDir);
24295
+ try {
24296
+ spec = await prisma.spec.create({
24297
+ data: {
24298
+ id: sid,
24299
+ projectId: issue2.projectId,
24300
+ title: issue2.title,
24301
+ source,
24302
+ stage: "brainstorming",
24303
+ priority,
24304
+ author: `GitHub \xB7 ${opts.author}`,
24305
+ objective: `${issue2.title}. ${backlink}`,
24306
+ payload
24307
+ }
24308
+ });
24309
+ } catch (e) {
24310
+ if (e.code === "P2002" && attempt < 2) continue;
24311
+ throw e;
24312
+ }
24313
+ }
24314
+ await prisma.githubIssue.update({
24315
+ where: { id: issue2.id },
24316
+ data: { status: "accepted", specId: spec.id }
24317
+ });
24318
+ await notifySynced("spec", spec.id);
24319
+ await notifySynced("githubIssue", issue2.id);
24320
+ return { spec, created: true };
24321
+ }
24322
+
24323
+ // src/routes/github-issues.ts
24324
+ var zPull = external_exports.object({
24325
+ state: external_exports.enum(["open", "all"]).optional(),
24326
+ limit: external_exports.number().int().min(1).max(1e3).optional()
24327
+ }).default({});
24328
+ var zAccept = external_exports.object({
24329
+ priority: external_exports.enum(["tinggi", "sedang", "rendah"]).optional(),
24330
+ source: external_exports.enum(["qa", "brief", "audit"]).optional()
24331
+ }).default({});
24332
+ var zAcceptMany = external_exports.object({
24333
+ ids: external_exports.array(external_exports.string().min(1)).min(1).max(100),
24334
+ priority: external_exports.enum(["tinggi", "sedang", "rendah"]).optional(),
24335
+ source: external_exports.enum(["qa", "brief", "audit"]).optional()
24336
+ });
24337
+ var STATUS = {
24338
+ "no-project": 404,
24339
+ "not-found": 404,
24340
+ "no-remote": 400,
24341
+ "not-github": 400,
24342
+ "issues-disabled": 400,
24343
+ unauthorized: 401,
24344
+ other: 502
24345
+ };
24346
+ var view7 = (i) => ({
24347
+ id: i.id,
24348
+ projectId: i.projectId,
24349
+ repoSlug: i.repoSlug,
24350
+ number: i.number,
24351
+ title: i.title,
24352
+ body: i.body,
24353
+ authorLogin: i.authorLogin,
24354
+ labels: Array.isArray(i.labels) ? i.labels : [],
24355
+ url: i.url,
24356
+ issueState: i.issueState,
24357
+ status: i.status,
24358
+ specId: i.specId,
24359
+ issueCreatedAt: i.issueCreatedAt.toISOString(),
24360
+ issueUpdatedAt: i.issueUpdatedAt.toISOString(),
24361
+ pulledAt: i.pulledAt.toISOString()
24362
+ });
24363
+ async function githubIssues(app2) {
24364
+ app2.post("/projects/:id/github/pull", async (req, reply) => {
24365
+ const { id } = req.params;
24366
+ const parsed = zPull.safeParse(req.body ?? {});
24367
+ if (!parsed.success) return reply.code(400).send({ error: parsed.error.flatten() });
24368
+ const r = await pullIssues(id, parsed.data);
24369
+ if (!r.ok) return reply.code(STATUS[r.kind] ?? 400).send({ error: r.error });
24370
+ return reply.code(200).send(r);
24371
+ });
24372
+ app2.get("/projects/:id/github/issues", async (req, reply) => {
24373
+ const { id } = req.params;
24374
+ const { status } = req.query;
24375
+ const project = await prisma.project.findUnique({ where: { id }, select: { id: true } });
24376
+ if (!project) return reply.code(404).send({ error: "not found" });
24377
+ const items = await prisma.githubIssue.findMany({
24378
+ where: { projectId: id, ...status ? { status } : {} },
24379
+ orderBy: [{ number: "desc" }]
24380
+ });
24381
+ return reply.send({ items: items.map(view7) });
24382
+ });
24383
+ app2.post("/github-issues/accept", async (req, reply) => {
24384
+ const parsed = zAcceptMany.safeParse(req.body ?? {});
24385
+ if (!parsed.success) return reply.code(400).send({ error: parsed.error.flatten() });
24386
+ const { ids, priority, source } = parsed.data;
24387
+ const author = req.user?.email ?? "system";
24388
+ const created = [];
24389
+ const failed = [];
24390
+ for (const id of ids) {
24391
+ const issue2 = await prisma.githubIssue.findUnique({ where: { id } });
24392
+ if (!issue2) {
24393
+ failed.push({ id, error: "not found" });
24394
+ continue;
24395
+ }
24396
+ try {
24397
+ created.push((await acceptGithubIssue(issue2, { author, priority, source })).spec);
24398
+ } catch (e) {
24399
+ failed.push({ id, error: e.message });
24400
+ }
24401
+ }
24402
+ return reply.code(201).send({ created, failed });
24403
+ });
24404
+ app2.post("/github-issues/:id/accept", async (req, reply) => {
24405
+ const { id } = req.params;
24406
+ const parsed = zAccept.safeParse(req.body ?? {});
24407
+ if (!parsed.success) return reply.code(400).send({ error: parsed.error.flatten() });
24408
+ const issue2 = await prisma.githubIssue.findUnique({ where: { id } });
24409
+ if (!issue2) return reply.code(404).send({ error: "not found" });
24410
+ const { spec, created } = await acceptGithubIssue(issue2, {
24411
+ author: req.user?.email ?? "system",
24412
+ priority: parsed.data.priority,
24413
+ source: parsed.data.source
24414
+ });
24415
+ return reply.code(created ? 201 : 200).send(created ? { spec } : { spec, alreadyPromoted: true });
24416
+ });
24417
+ app2.post("/github-issues/:id/reject", async (req, reply) => {
24418
+ const { id } = req.params;
24419
+ const issue2 = await prisma.githubIssue.findUnique({ where: { id } });
24420
+ if (!issue2) return reply.code(404).send({ error: "not found" });
24421
+ const row = await prisma.githubIssue.update({ where: { id }, data: { status: "rejected" } });
24422
+ await notifySynced("githubIssue", id);
24423
+ return reply.send({ id: row.id, status: row.status });
24424
+ });
24425
+ app2.post("/github-issues/:id/unlink", async (req, reply) => {
24426
+ const { id } = req.params;
24427
+ const issue2 = await prisma.githubIssue.findUnique({ where: { id } });
24428
+ if (!issue2) return reply.code(404).send({ error: "not found" });
24429
+ const row = await prisma.githubIssue.update({ where: { id }, data: { status: "new", specId: null } });
24430
+ await notifySynced("githubIssue", id);
24431
+ return reply.send({ id: row.id, status: row.status, specId: row.specId });
24432
+ });
24433
+ }
24434
+
23718
24435
  // src/app.ts
23719
24436
  var import_multipart = __toESM(require_multipart2(), 1);
23720
24437
 
@@ -23796,7 +24513,7 @@ function cookieOpts() {
23796
24513
  }
23797
24514
 
23798
24515
  // src/routes/auth.ts
23799
- var view7 = (u) => ({ id: u.id, email: u.email, createdAt: u.createdAt.toISOString() });
24516
+ var view8 = (u) => ({ id: u.id, email: u.email, createdAt: u.createdAt.toISOString() });
23800
24517
  async function issue(reply, userId) {
23801
24518
  const token = await createSession2(userId);
23802
24519
  reply.setCookie(COOKIE_NAME, token, cookieOpts());
@@ -23814,7 +24531,7 @@ async function auth_default(app2) {
23814
24531
  data: { email: p.data.email, passwordHash: await hashPassword(p.data.password) }
23815
24532
  });
23816
24533
  await issue(reply, user.id);
23817
- return { user: view7(user) };
24534
+ return { user: view8(user) };
23818
24535
  });
23819
24536
  app2.post("/auth/login", async (req, reply) => {
23820
24537
  if (loginThrottle(req.ip).blocked) return reply.code(429).send({ error: "too many attempts" });
@@ -23827,7 +24544,7 @@ async function auth_default(app2) {
23827
24544
  }
23828
24545
  clearLoginFails(req.ip);
23829
24546
  await issue(reply, user.id);
23830
- return { user: view7(user) };
24547
+ return { user: view8(user) };
23831
24548
  });
23832
24549
  app2.post("/auth/logout", async (req, reply) => {
23833
24550
  const token = req.cookies?.[COOKIE_NAME];
@@ -23835,7 +24552,7 @@ async function auth_default(app2) {
23835
24552
  reply.clearCookie(COOKIE_NAME, { path: "/" });
23836
24553
  return reply.code(204).send();
23837
24554
  });
23838
- app2.get("/auth/users", async () => (await prisma.user.findMany({ orderBy: { createdAt: "asc" } })).map(view7));
24555
+ app2.get("/auth/users", async () => (await prisma.user.findMany({ orderBy: { createdAt: "asc" } })).map(view8));
23839
24556
  app2.post("/auth/users", async (req, reply) => {
23840
24557
  const p = zSignup.safeParse(req.body);
23841
24558
  if (!p.success) return reply.code(400).send({ error: p.error.flatten() });
@@ -23844,7 +24561,7 @@ async function auth_default(app2) {
23844
24561
  const user = await prisma.user.create({
23845
24562
  data: { email: p.data.email, passwordHash: await hashPassword(p.data.password) }
23846
24563
  });
23847
- return view7(user);
24564
+ return view8(user);
23848
24565
  });
23849
24566
  app2.delete("/auth/users/:id", async (req, reply) => {
23850
24567
  if (await prisma.user.count() <= 1) return reply.code(400).send({ error: "tak bisa hapus user terakhir" });
@@ -23864,7 +24581,7 @@ async function auth_default(app2) {
23864
24581
  });
23865
24582
  await deleteUserSessions(user.id);
23866
24583
  await issue(reply, user.id);
23867
- return { user: view7(user) };
24584
+ return { user: view8(user) };
23868
24585
  });
23869
24586
  }
23870
24587
 
@@ -23943,15 +24660,15 @@ async function agent_tokens_default(app2) {
23943
24660
  app2.post("/agent-tokens", async (req, reply) => {
23944
24661
  const p = zAgentTokenCreate.safeParse(req.body);
23945
24662
  if (!p.success) return reply.code(400).send({ error: p.error.flatten() });
23946
- const { view: view8, token } = await issueAgentToken({ ...p.data, createdBy: req.user?.id });
23947
- return reply.code(201).send({ ...view8, token });
24663
+ const { view: view9, token } = await issueAgentToken({ ...p.data, createdBy: req.user?.id });
24664
+ return reply.code(201).send({ ...view9, token });
23948
24665
  });
23949
24666
  app2.patch("/agent-tokens/:id", async (req, reply) => {
23950
24667
  const p = zAgentTokenPatch.safeParse(req.body);
23951
24668
  if (!p.success) return reply.code(400).send({ error: p.error.flatten() });
23952
24669
  const { id } = req.params;
23953
- const view8 = await patchAgentToken(id, p.data);
23954
- return view8 ? reply.send(view8) : reply.code(404).send({ error: "not found" });
24670
+ const view9 = await patchAgentToken(id, p.data);
24671
+ return view9 ? reply.send(view9) : reply.code(404).send({ error: "not found" });
23955
24672
  });
23956
24673
  app2.delete("/agent-tokens/:id", async (req, reply) => {
23957
24674
  const { id } = req.params;
@@ -24004,6 +24721,7 @@ function capabilityForRoute(method, path) {
24004
24721
  if (top === "specs") return rw("backlog");
24005
24722
  if (top === "notifications") return rw("notifications");
24006
24723
  if (top === "tickets") return rw("support");
24724
+ if (top === "github-issues") return rw("support");
24007
24725
  if (top === "vps") return rw("vps");
24008
24726
  if (top === "prds") return rw("docs");
24009
24727
  if (top === "terminal") {
@@ -24013,6 +24731,7 @@ function capabilityForRoute(method, path) {
24013
24731
  if (top === "projects") {
24014
24732
  const sub = seg[2];
24015
24733
  if (sub === "docs" || sub === "prds") return rw("docs");
24734
+ if (sub === "github") return rw("support");
24016
24735
  if (sub && IDE_SUBS.has(sub)) return rw("ide");
24017
24736
  return rw("projects");
24018
24737
  }
@@ -24107,6 +24826,7 @@ function buildApp({ requireAuth = true } = {}) {
24107
24826
  await api.register(codex);
24108
24827
  await api.register(lead_default);
24109
24828
  await api.register(custom_agents_default);
24829
+ await api.register(githubIssues);
24110
24830
  }, { prefix: "/api" });
24111
24831
  if (process.env.NODE_ENV === "production") {
24112
24832
  const dist = pickWebDir(dirname12(fileURLToPath4(import.meta.url)), process.env, existsSync11);