hanoman 0.1.40 → 0.1.42

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.
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.1.40",
3
- "sha": "bf5c0ae",
4
- "builtAt": "2026-08-15T13:13:30.676Z"
2
+ "version": "0.1.42",
3
+ "sha": "ad6403a",
4
+ "builtAt": "2026-08-17T00:53:19.226Z"
5
5
  }
package/dist/cli.js CHANGED
@@ -4411,7 +4411,7 @@ var init_auto_merge = __esm({
4411
4411
  });
4412
4412
 
4413
4413
  // ../shared/src/entities.ts
4414
- var zProject, zBriefPayload, zQaPayload, zGoalPayload, zSpecBlocker, zSourceChange, zSpec, NOTIFY_SOUNDS, zCodex, CODEX_DEFAULTS, zSourceCommon, zScheduler, SCHEDULER_DEFAULTS, zGoal, GOAL_DEFAULTS, zConflict, CONFLICT_DEFAULTS, CHANGELOG_ENGINE_DEFAULTS, zLeadEngine, zLead, LEAD_DEFAULTS, zSetting, zNotification, zDocFile, zDeviceTokenView;
4414
+ var zProject, zBriefPayload, zQaPayload, zGoalPayload, zSpecBlocker, zSourceChange, zManualDone, zSpec, NOTIFY_SOUNDS, zCodex, CODEX_DEFAULTS, zSourceCommon, zScheduler, SCHEDULER_DEFAULTS, zGoal, GOAL_DEFAULTS, zConflict, CONFLICT_DEFAULTS, CHANGELOG_ENGINE_DEFAULTS, zLeadEngine, zLead, LEAD_DEFAULTS, zSetting, zNotification, zDocFile, zDeviceTokenView;
4415
4415
  var init_entities = __esm({
4416
4416
  "../shared/src/entities.ts"() {
4417
4417
  "use strict";
@@ -4469,6 +4469,11 @@ var init_entities = __esm({
4469
4469
  by: external_exports.string(),
4470
4470
  payload: external_exports.unknown().optional()
4471
4471
  });
4472
+ zManualDone = external_exports.object({
4473
+ at: external_exports.string(),
4474
+ by: external_exports.string(),
4475
+ reason: external_exports.string().optional()
4476
+ });
4472
4477
  zSpec = external_exports.object({
4473
4478
  id: external_exports.string(),
4474
4479
  projectId: external_exports.string(),
@@ -4500,7 +4505,10 @@ var init_entities = __esm({
4500
4505
  // SPEC-546 · ADR-0109 · jejak konversi type. `.default([])` menjaga respons/klien versi lama
4501
4506
  // tetap parse; kolom DB-nya `Json?` sehingga baris yang belum pernah dikonversi mengirim
4502
4507
  // `null` — pemakai UI menulis `spec.sourceHistory ?? []`, cermin `blockedBy`.
4503
- sourceHistory: external_exports.array(zSourceChange).default([])
4508
+ sourceHistory: external_exports.array(zSourceChange).default([]),
4509
+ // SPEC-804 · ADR-0120 · terisi hanya bila item ditandai selesai manual; null = selesai lewat
4510
+ // sesi atau belum selesai. `.nullable().default(null)` menjaga respons/klien versi lama parse.
4511
+ manualDone: zManualDone.nullable().default(null)
4504
4512
  });
4505
4513
  NOTIFY_SOUNDS = [
4506
4514
  "off",
@@ -5008,7 +5016,7 @@ var init_cron_expr = __esm({
5008
5016
  });
5009
5017
 
5010
5018
  // ../shared/src/dto.ts
5011
- var zIssueDeviceToken, zSessionResult, zSessionHistory, zCreateProject, zProjectId, zRenameProject, zUpdateProject, zCreateSpec, zPatchSpec, zChangeSpecSource, zIntegrate, zSessionSummary, zProjectView, zSchedulerQueueItem, zSchedulerSourceView, zSchedulerSessionView, zSchedulerQueueCounts, zSchedulerState, zLeadDecisionView, zLeadFlowView, zLeadAnswer, zLeadProjectStatus, zLeadStatusView, zFlow, zPrdBrief, zPrdDoc, zBreakdownItem, zBreakdownDoc, zBatchCreateSpec, zEscalationTarget, zEscalationPrefill, zAuditEscalation, zAuditEscalationView, zTerminalSession, zDocFileContent, zDocIndexCat, zDocIndex, HOST_RE, USER_RE, zCreateVps, zPatchVps, USER_ROLES, zUserRole, zLogin, zSignup, zSetup, zChangePassword, zCreateClientAccount, zUpdateClientAccount, zVpsCheck, zMarkNa, zMarkNaBulk, zAttest, zRemediate, UPDATE_RESTART_EXIT, zUpdateApplyBody, zTicketView, zTicketAttachmentView, zTicketDetail, zTicketEditInput, zHelpInfo, zPublicTicketStatus, zCronExpr, zCreateCron, zPatchCron, zSchedulerCron, zSchedulerCronRun;
5019
+ var zIssueDeviceToken, zSessionResult, zSessionHistory, zCreateProject, zProjectId, zRenameProject, zUpdateProject, zCreateSpec, zPatchSpec, zChangeSpecSource, zMarkSpecDone, zIntegrate, zSessionSummary, zProjectView, zSchedulerQueueItem, zSchedulerSourceView, zSchedulerSessionView, zSchedulerQueueCounts, zSchedulerState, zLeadDecisionView, zLeadFlowView, zLeadAnswer, zLeadProjectStatus, zLeadStatusView, zFlow, zPrdBrief, zPrdDoc, zBreakdownItem, zBreakdownDoc, zBatchCreateSpec, zEscalationTarget, zEscalationPrefill, zAuditEscalation, zAuditEscalationView, zTerminalSession, zDocFileContent, zDocIndexCat, zDocIndex, HOST_RE, USER_RE, zCreateVps, zPatchVps, USER_ROLES, zUserRole, zLogin, zSignup, zSetup, zChangePassword, zCreateClientAccount, zUpdateClientAccount, zVpsCheck, zMarkNa, zMarkNaBulk, zAttest, zRemediate, UPDATE_RESTART_EXIT, zUpdateApplyBody, zTicketView, zTicketAttachmentView, zTicketDetail, zTicketEditInput, zHelpInfo, zPublicTicketStatus, zCronExpr, zCreateCron, zPatchCron, zSchedulerCron, zSchedulerCronRun;
5012
5020
  var init_dto = __esm({
5013
5021
  "../shared/src/dto.ts"() {
5014
5022
  "use strict";
@@ -5115,6 +5123,10 @@ var init_dto = __esm({
5115
5123
  if (o.payload !== void 0 && !payloadMatchesSource(o.source, o.payload))
5116
5124
  ctx.addIssue({ code: external_exports.ZodIssueCode.custom, path: ["payload"], message: "bentuk payload tak cocok dengan source" });
5117
5125
  });
5126
+ zMarkSpecDone = external_exports.object({
5127
+ reason: external_exports.string().trim().max(280).optional(),
5128
+ confirm: external_exports.boolean().optional()
5129
+ });
5118
5130
  zIntegrate = external_exports.object({
5119
5131
  op: external_exports.enum(["merge", "rebase"]),
5120
5132
  target: external_exports.string().regex(/^(local|origin):.+/)
@@ -5586,6 +5598,8 @@ var init_api = __esm({
5586
5598
  specIntegrate: (id) => `${API}/specs/${id}/integrate`,
5587
5599
  // SPEC-546 · ADR-0109 · ubah type/source item in-place (operasi khusus, bukan field PATCH).
5588
5600
  specSource: (id) => `${API}/specs/${id}/source`,
5601
+ // SPEC-804 · ADR-0120 · tandai item selesai manual (operasi khusus, bukan field PATCH).
5602
+ specDone: (id) => `${API}/specs/${id}/done`,
5589
5603
  specReview: (id) => `${API}/specs/${id}/review`,
5590
5604
  specReviewFile: (id, path) => `${API}/specs/${id}/review/${path}`,
5591
5605
  settings: `${API}/settings`,
@@ -6152,6 +6166,9 @@ var init_webhook = __esm({
6152
6166
  "autoMerge",
6153
6167
  "createdAt",
6154
6168
  "startedAt",
6169
+ // SPEC-804 · ADR-0120 · penerima harus bisa membedakan "selesai lewat sesi" dari "ditandai
6170
+ // manusia" tanpa mendiff dua amplop. Peristiwanya tetap `spec.stage_changed`.
6171
+ "manualDone",
6155
6172
  "updatedAt"
6156
6173
  ],
6157
6174
  projectIdField: "projectId",
@@ -6190,6 +6207,7 @@ var init_webhook = __esm({
6190
6207
  autoMerge: null,
6191
6208
  createdAt: "2026-08-01T02:10:00.000Z",
6192
6209
  startedAt: "2026-08-01T02:12:31.000Z",
6210
+ manualDone: null,
6193
6211
  updatedAt: "2026-08-01T09:41:22.000Z"
6194
6212
  }
6195
6213
  },
package/dist/server.js CHANGED
@@ -4557,7 +4557,7 @@ function cmpVersion(a, b) {
4557
4557
  }
4558
4558
  return 0;
4559
4559
  }
4560
- var zProject, zBriefPayload, zQaPayload, zGoalPayload, zSpecBlocker, zSourceChange, zSpec, NOTIFY_SOUNDS, MODELS, EFFORTS, 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, CHANGELOG_ENGINE_DEFAULTS, zLeadEngine, zLead, LEAD_DEFAULTS, zSetting, zNotification, zDocFile, zDeviceTokenView;
4560
+ var zProject, zBriefPayload, zQaPayload, zGoalPayload, zSpecBlocker, zSourceChange, zManualDone, zSpec, NOTIFY_SOUNDS, MODELS, EFFORTS, 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, CHANGELOG_ENGINE_DEFAULTS, zLeadEngine, zLead, LEAD_DEFAULTS, zSetting, zNotification, zDocFile, zDeviceTokenView;
4561
4561
  var init_entities = __esm({
4562
4562
  "../shared/src/entities.ts"() {
4563
4563
  "use strict";
@@ -4615,6 +4615,11 @@ var init_entities = __esm({
4615
4615
  by: external_exports.string(),
4616
4616
  payload: external_exports.unknown().optional()
4617
4617
  });
4618
+ zManualDone = external_exports.object({
4619
+ at: external_exports.string(),
4620
+ by: external_exports.string(),
4621
+ reason: external_exports.string().optional()
4622
+ });
4618
4623
  zSpec = external_exports.object({
4619
4624
  id: external_exports.string(),
4620
4625
  projectId: external_exports.string(),
@@ -4646,7 +4651,10 @@ var init_entities = __esm({
4646
4651
  // SPEC-546 · ADR-0109 · jejak konversi type. `.default([])` menjaga respons/klien versi lama
4647
4652
  // tetap parse; kolom DB-nya `Json?` sehingga baris yang belum pernah dikonversi mengirim
4648
4653
  // `null` — pemakai UI menulis `spec.sourceHistory ?? []`, cermin `blockedBy`.
4649
- sourceHistory: external_exports.array(zSourceChange).default([])
4654
+ sourceHistory: external_exports.array(zSourceChange).default([]),
4655
+ // SPEC-804 · ADR-0120 · terisi hanya bila item ditandai selesai manual; null = selesai lewat
4656
+ // sesi atau belum selesai. `.nullable().default(null)` menjaga respons/klien versi lama parse.
4657
+ manualDone: zManualDone.nullable().default(null)
4650
4658
  });
4651
4659
  NOTIFY_SOUNDS = [
4652
4660
  "off",
@@ -5450,7 +5458,7 @@ var init_cron_expr = __esm({
5450
5458
  function flowForSource(source) {
5451
5459
  return source === "qa" ? "qa" : source === "audit" ? "audit" : source === "goal" ? "goal" : "feature";
5452
5460
  }
5453
- var zIssueDeviceToken, zSessionResult, zSessionHistory, zCreateProject, zProjectId, zRenameProject, zUpdateProject, zCreateSpec, zPatchSpec, zChangeSpecSource, zIntegrate, zSessionSummary, zProjectView, zSchedulerQueueItem, zSchedulerSourceView, zSchedulerSessionView, zSchedulerQueueCounts, zSchedulerState, zLeadDecisionView, zLeadFlowView, zLeadAnswer, zLeadProjectStatus, zLeadStatusView, zFlow, zPrdBrief, zPrdDoc, zBreakdownItem, zBreakdownDoc, zBatchCreateSpec, zEscalationTarget, zEscalationPrefill, zAuditEscalation, zAuditEscalationView, zTerminalSession, zDocFileContent, zDocIndexCat, zDocIndex, HOST_RE, USER_RE, zCreateVps, zPatchVps, USER_ROLES, zUserRole, zLogin, zSignup, zSetup, zChangePassword, zCreateClientAccount, zUpdateClientAccount, zVpsCheck, zMarkNa, zMarkNaBulk, zAttest, zRemediate, UPDATE_RESTART_EXIT, zUpdateApplyBody, zTicketView, zTicketAttachmentView, zTicketDetail, zTicketEditInput, zHelpInfo, zPublicTicketStatus, zCronExpr, zCreateCron, zPatchCron, zSchedulerCron, zSchedulerCronRun;
5461
+ var zIssueDeviceToken, zSessionResult, zSessionHistory, zCreateProject, zProjectId, zRenameProject, zUpdateProject, zCreateSpec, zPatchSpec, zChangeSpecSource, zMarkSpecDone, zIntegrate, zSessionSummary, zProjectView, zSchedulerQueueItem, zSchedulerSourceView, zSchedulerSessionView, zSchedulerQueueCounts, zSchedulerState, zLeadDecisionView, zLeadFlowView, zLeadAnswer, zLeadProjectStatus, zLeadStatusView, zFlow, zPrdBrief, zPrdDoc, zBreakdownItem, zBreakdownDoc, zBatchCreateSpec, zEscalationTarget, zEscalationPrefill, zAuditEscalation, zAuditEscalationView, zTerminalSession, zDocFileContent, zDocIndexCat, zDocIndex, HOST_RE, USER_RE, zCreateVps, zPatchVps, USER_ROLES, zUserRole, zLogin, zSignup, zSetup, zChangePassword, zCreateClientAccount, zUpdateClientAccount, zVpsCheck, zMarkNa, zMarkNaBulk, zAttest, zRemediate, UPDATE_RESTART_EXIT, zUpdateApplyBody, zTicketView, zTicketAttachmentView, zTicketDetail, zTicketEditInput, zHelpInfo, zPublicTicketStatus, zCronExpr, zCreateCron, zPatchCron, zSchedulerCron, zSchedulerCronRun;
5454
5462
  var init_dto = __esm({
5455
5463
  "../shared/src/dto.ts"() {
5456
5464
  "use strict";
@@ -5557,6 +5565,10 @@ var init_dto = __esm({
5557
5565
  if (o.payload !== void 0 && !payloadMatchesSource(o.source, o.payload))
5558
5566
  ctx.addIssue({ code: external_exports.ZodIssueCode.custom, path: ["payload"], message: "bentuk payload tak cocok dengan source" });
5559
5567
  });
5568
+ zMarkSpecDone = external_exports.object({
5569
+ reason: external_exports.string().trim().max(280).optional(),
5570
+ confirm: external_exports.boolean().optional()
5571
+ });
5560
5572
  zIntegrate = external_exports.object({
5561
5573
  op: external_exports.enum(["merge", "rebase"]),
5562
5574
  target: external_exports.string().regex(/^(local|origin):.+/)
@@ -6028,6 +6040,8 @@ var init_api = __esm({
6028
6040
  specIntegrate: (id) => `${API}/specs/${id}/integrate`,
6029
6041
  // SPEC-546 · ADR-0109 · ubah type/source item in-place (operasi khusus, bukan field PATCH).
6030
6042
  specSource: (id) => `${API}/specs/${id}/source`,
6043
+ // SPEC-804 · ADR-0120 · tandai item selesai manual (operasi khusus, bukan field PATCH).
6044
+ specDone: (id) => `${API}/specs/${id}/done`,
6031
6045
  specReview: (id) => `${API}/specs/${id}/review`,
6032
6046
  specReviewFile: (id, path2) => `${API}/specs/${id}/review/${path2}`,
6033
6047
  settings: `${API}/settings`,
@@ -6749,6 +6763,9 @@ var init_webhook = __esm({
6749
6763
  "autoMerge",
6750
6764
  "createdAt",
6751
6765
  "startedAt",
6766
+ // SPEC-804 · ADR-0120 · penerima harus bisa membedakan "selesai lewat sesi" dari "ditandai
6767
+ // manusia" tanpa mendiff dua amplop. Peristiwanya tetap `spec.stage_changed`.
6768
+ "manualDone",
6752
6769
  "updatedAt"
6753
6770
  ],
6754
6771
  projectIdField: "projectId",
@@ -6787,6 +6804,7 @@ var init_webhook = __esm({
6787
6804
  autoMerge: null,
6788
6805
  createdAt: "2026-08-01T02:10:00.000Z",
6789
6806
  startedAt: "2026-08-01T02:12:31.000Z",
6807
+ manualDone: null,
6790
6808
  updatedAt: "2026-08-01T09:41:22.000Z"
6791
6809
  }
6792
6810
  },
@@ -9738,8 +9756,8 @@ function assertRuntimeBoundary(env, runtime) {
9738
9756
  if (runtime.uid === 0) throw new Error("production Hanoman harus berjalan sebagai user non-root");
9739
9757
  if (env.HANOMAN_SESSION_SANDBOX !== "podman")
9740
9758
  throw new Error("HANOMAN_SESSION_SANDBOX=podman wajib di production");
9741
- if (!env.HANOMAN_PUBLIC_ORIGINS || !env.HANOMAN_CONTROL_ORIGINS)
9742
- throw new Error("public/control origin wajib dipisahkan di production");
9759
+ if (!env.HANOMAN_CONTROL_ORIGINS || !env.HANOMAN_PUBLIC_ORIGINS && !env.HANOMAN_SINGLE_ORIGIN)
9760
+ throw new Error("public/control origin wajib dipisahkan di production (HANOMAN_SINGLE_ORIGIN=1 untuk sadar memilih satu origin)");
9743
9761
  if (!env.HANOMAN_TRUST_PROXY) throw new Error("trusted proxy hop/CIDR wajib di production");
9744
9762
  if (runtime.host !== "127.0.0.1" && runtime.host !== "::1" && runtime.host !== "localhost")
9745
9763
  throw new Error("origin Hanoman harus bind loopback; buka hanya reverse proxy");
@@ -10155,12 +10173,24 @@ function broadcast(a, f) {
10155
10173
  const msg = frame(f);
10156
10174
  for (const c of a.clients) c.send(msg);
10157
10175
  }
10176
+ function flushOutput(a) {
10177
+ if (a.flushTimer) {
10178
+ clearTimeout(a.flushTimer);
10179
+ a.flushTimer = void 0;
10180
+ }
10181
+ if (!a.pending) return;
10182
+ const d = a.pending;
10183
+ a.pending = "";
10184
+ a.scrollback = trimScrollback(a.scrollback + d);
10185
+ broadcast(a, { t: "data", d });
10186
+ }
10158
10187
  function open(id) {
10159
10188
  const pty = spawnPty("attach-session", "-d", "-t", name(id));
10160
- const a = { pty, scrollback: "", clients: /* @__PURE__ */ new Set(), lastPhases: "" };
10189
+ const a = { pty, scrollback: "", clients: /* @__PURE__ */ new Set(), lastPhases: "", pending: "" };
10161
10190
  pty.onData((d) => {
10162
- a.scrollback = (a.scrollback + d).slice(-MAX_SCROLLBACK);
10163
- broadcast(a, { t: "data", d });
10191
+ a.pending += d;
10192
+ if (a.pending.length >= COALESCE_MAX_BYTES) flushOutput(a);
10193
+ else if (!a.flushTimer) a.flushTimer = setTimeout(() => flushOutput(a), COALESCE_MS);
10164
10194
  });
10165
10195
  pty.onExit(() => {
10166
10196
  if (attached.get(id) === a) drop(id);
@@ -10172,6 +10202,7 @@ function open(id) {
10172
10202
  function drop(id) {
10173
10203
  const a = attached.get(id);
10174
10204
  if (!a) return;
10205
+ flushOutput(a);
10175
10206
  attached.delete(id);
10176
10207
  a.pty.kill();
10177
10208
  for (const c of a.clients) c.close();
@@ -10180,6 +10211,7 @@ function drop(id) {
10180
10211
  function end(id, code) {
10181
10212
  const a = attached.get(id);
10182
10213
  if (!a) return;
10214
+ flushOutput(a);
10183
10215
  broadcast(a, { t: "exit", code });
10184
10216
  drop(id);
10185
10217
  }
@@ -10273,7 +10305,7 @@ function spawnPty(...args) {
10273
10305
  );
10274
10306
  }
10275
10307
  }
10276
- var socket, PREFIX, MAX_SCROLLBACK, POLL_MS, markerFilled, attached, claudeBin, shellBin, codexBin, agentBin, rootBypassEnv, frame, name, promptFilePath, goalGatePath, goalStatePath, agentsFilePath, NO_SERVER, TmuxError, sq, 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;
10308
+ var socket, PREFIX, MAX_SCROLLBACK, SCROLLBACK_SLACK, POLL_MS, markerFilled, attached, claudeBin, shellBin, codexBin, agentBin, rootBypassEnv, frame, name, promptFilePath, goalGatePath, goalStatePath, agentsFilePath, NO_SERVER, TmuxError, sq, idFor, FMT, listSessions, liveDecisions, getSession, hooks, emitBirth, emitDeath, customAgentSource, customAgentsFor, sleep, paneText, dialogIO, DIALOG_CAPTURE_LINES, GOAL_ARMED_MARKERS, goalArmed, COALESCE_MS, COALESCE_MAX_BYTES, trimScrollback, phaseKey, paneComplete, sessionFinished, poll, detach;
10277
10309
  var init_pty = __esm({
10278
10310
  "src/services/pty.ts"() {
10279
10311
  "use strict";
@@ -10287,6 +10319,7 @@ var init_pty = __esm({
10287
10319
  socket = () => effectiveStr("HANOMAN_TMUX_SOCKET") ?? "hanoman";
10288
10320
  PREFIX = "hanoman-";
10289
10321
  MAX_SCROLLBACK = 256 * 1024;
10322
+ SCROLLBACK_SLACK = 64 * 1024;
10290
10323
  POLL_MS = 500;
10291
10324
  markerFilled = (f) => {
10292
10325
  try {
@@ -10400,6 +10433,9 @@ var init_pty = __esm({
10400
10433
  const text = paneText(id);
10401
10434
  return GOAL_ARMED_MARKERS[agent].some((m) => text.includes(m));
10402
10435
  };
10436
+ COALESCE_MS = 16;
10437
+ COALESCE_MAX_BYTES = 64 * 1024;
10438
+ trimScrollback = (s2) => s2.length > MAX_SCROLLBACK + SCROLLBACK_SLACK ? s2.slice(-MAX_SCROLLBACK) : s2;
10403
10439
  phaseKey = (phases, complete) => JSON.stringify({ phases, complete });
10404
10440
  paneComplete = (p) => !!p.flow && !!p.phaseFile && sessionComplete(readPhases(p.phaseFile, p.flow), p.cwd, p.specId);
10405
10441
  sessionFinished = (id) => {
@@ -10531,6 +10567,7 @@ __export(sync_exports, {
10531
10567
  __DATE_FIELDS: () => __DATE_FIELDS,
10532
10568
  __FIELDS: () => __FIELDS,
10533
10569
  __FIELDS_FOR_TEST: () => __FIELDS_FOR_TEST,
10570
+ __JSON_FIELDS: () => __JSON_FIELDS,
10534
10571
  applyPush: () => applyPush,
10535
10572
  backfillFeed: () => backfillFeed,
10536
10573
  consumeTombstoneOnRecreate: () => consumeTombstoneOnRecreate,
@@ -10774,7 +10811,7 @@ async function upsertLocal(entity, id, version2, data) {
10774
10811
  function setAcceptedHook(hook) {
10775
10812
  onAccepted = hook;
10776
10813
  }
10777
- var SYNCED, DELEGATE, FIELDS, DATE_FIELDS, PARENTS, __FIELDS, __DATE_FIELDS, NUMBER_FIELDS, BOOLEAN_FIELDS, JSON_FIELDS, onAccepted, __FIELDS_FOR_TEST;
10814
+ var SYNCED, DELEGATE, FIELDS, DATE_FIELDS, PARENTS, __FIELDS, __DATE_FIELDS, NUMBER_FIELDS, BOOLEAN_FIELDS, JSON_FIELDS, __JSON_FIELDS, onAccepted, __FIELDS_FOR_TEST;
10778
10815
  var init_sync = __esm({
10779
10816
  "src/services/sync.ts"() {
10780
10817
  "use strict";
@@ -10806,7 +10843,10 @@ var init_sync = __esm({
10806
10843
  // TETAP berhasil, jadi kolom yang terlewat di sini mendarat sebagai null palsu di tiap client
10807
10844
  // tanpa satu pun error (kelas gagal-senyap ADR-0090/0093/0094/0105). BUKAN DATE_FIELDS —
10808
10845
  // `at` hidup di dalam JSON-nya, kolomnya sendiri bukan DateTime.
10809
- spec: ["projectId", "title", "source", "stage", "priority", "author", "objective", "payload", "branchFrom", "baseSha", "headSha", "dependsOn", "sourceHistory", "createdAt", "startedAt", "doneAt", "updatedAt"],
10846
+ // SPEC-804 · ADR-0120 · manualDone ikut menyeberang: "item ini ditandai selesai manusia" adalah
10847
+ // bagian keadaan yang harus dilihat sama oleh semua mesin — di antaranya gerbang auto-merge.
10848
+ // BUKAN DATE_FIELDS, alasan yang sama dengan sourceHistory.
10849
+ spec: ["projectId", "title", "source", "stage", "priority", "author", "objective", "payload", "branchFrom", "baseSha", "headSha", "dependsOn", "sourceHistory", "manualDone", "createdAt", "startedAt", "doneAt", "updatedAt"],
10810
10850
  vps: ["name", "host", "port", "user", "health", "audit", "hardened", "lastSeenAt", "lastAuditAt", "updatedAt"],
10811
10851
  sessionResult: ["projectId", "specId", "oldStage", "newStage", "commitSha", "branch", "prUrl", "status", "deviceId", "author", "createdAt", "updatedAt"],
10812
10852
  // SPEC-268 · ADR-0066 · metadata tiket (lampiran biner tak disync). accessKeyHash wajib
@@ -10874,12 +10914,14 @@ var init_sync = __esm({
10874
10914
  "spec:payload",
10875
10915
  "spec:dependsOn",
10876
10916
  "spec:sourceHistory",
10917
+ "spec:manualDone",
10877
10918
  "vps:health",
10878
10919
  "vps:audit",
10879
10920
  "customAgent:tools",
10880
10921
  "customAgent:mentions",
10881
10922
  "githubIssue:labels"
10882
10923
  ]);
10924
+ __JSON_FIELDS = JSON_FIELDS;
10883
10925
  __FIELDS_FOR_TEST = FIELDS;
10884
10926
  }
10885
10927
  });
@@ -25773,6 +25815,65 @@ function appendSourceHistory(current, entry) {
25773
25815
 
25774
25816
  // src/routes/specs.ts
25775
25817
  init_notifications();
25818
+
25819
+ // src/services/spec-complete.ts
25820
+ init_db();
25821
+ init_notifications();
25822
+
25823
+ // src/services/session-result.ts
25824
+ init_db();
25825
+ import { randomUUID as randomUUID3 } from "node:crypto";
25826
+ var WHITELIST = [
25827
+ "projectId",
25828
+ "specId",
25829
+ "oldStage",
25830
+ "newStage",
25831
+ "commitSha",
25832
+ "branch",
25833
+ "prUrl",
25834
+ "status",
25835
+ "deviceId",
25836
+ "author"
25837
+ ];
25838
+ async function recordSessionResult(input) {
25839
+ const id = randomUUID3();
25840
+ const data = { id };
25841
+ for (const f of WHITELIST) if (input[f] !== void 0 && input[f] !== null) data[f] = input[f];
25842
+ if (data.status === void 0) data.status = "done";
25843
+ if (data.projectId === void 0) throw new Error("session result butuh projectId");
25844
+ await prisma.sessionResult.create({ data });
25845
+ await notifySynced("sessionResult", id);
25846
+ return { id };
25847
+ }
25848
+
25849
+ // src/services/spec-complete.ts
25850
+ async function completeSpecManually(spec, input) {
25851
+ const at = input.at ?? /* @__PURE__ */ new Date();
25852
+ const manualDone = {
25853
+ at: at.toISOString(),
25854
+ by: input.by,
25855
+ ...input.reason ? { reason: input.reason } : {}
25856
+ };
25857
+ const { count } = await prisma.spec.updateMany({
25858
+ where: { id: spec.id, stage: { not: "done" } },
25859
+ data: { stage: "done", manualDone }
25860
+ });
25861
+ if (count === 0) return { ok: false };
25862
+ await recordCompletion(spec.id, spec.title, spec.projectId);
25863
+ await recordSessionResult({
25864
+ projectId: spec.projectId,
25865
+ specId: spec.id,
25866
+ oldStage: spec.stage,
25867
+ newStage: "done",
25868
+ status: "done",
25869
+ author: input.by
25870
+ }).catch(() => {
25871
+ });
25872
+ await notifySynced("spec", spec.id);
25873
+ return { ok: true, spec: await prisma.spec.findUnique({ where: { id: spec.id } }) };
25874
+ }
25875
+
25876
+ // src/routes/specs.ts
25776
25877
  init_stage_machine();
25777
25878
 
25778
25879
  // src/services/stage-artifacts.ts
@@ -28693,6 +28794,23 @@ ${item.context}` : item.context;
28693
28794
  await notifySynced("spec", id);
28694
28795
  return updated;
28695
28796
  });
28797
+ app2.post("/specs/:id/done", async (req, reply) => {
28798
+ const { id } = req.params;
28799
+ const parsed = zMarkSpecDone.safeParse(req.body ?? {});
28800
+ if (!parsed.success) return reply.code(400).send({ error: parsed.error.flatten() });
28801
+ const spec = await prisma.spec.findUnique({ where: { id } });
28802
+ if (!spec) return reply.code(404).send({ error: "not found" });
28803
+ if (spec.stage === "done") return reply.code(409).send({ error: "backlog item sudah selesai" });
28804
+ const live = listSessions().find((s2) => s2.specId === id && !s2.exited);
28805
+ if (live && parsed.data.confirm !== true)
28806
+ return reply.code(409).send({ error: "confirm-required", session: { id: live.id, agent: live.agent } });
28807
+ const res = await completeSpecManually(spec, {
28808
+ by: req.user?.email ?? "system",
28809
+ reason: parsed.data.reason || void 0
28810
+ });
28811
+ if (!res.ok) return reply.code(409).send({ error: "backlog item sudah selesai" });
28812
+ return res.spec;
28813
+ });
28696
28814
  app2.get("/specs/:id/docs", async (req) => ({ files: await listSpecDocs(req.params.id) }));
28697
28815
  app2.get("/specs/:id/docs/*", async (req, reply) => {
28698
28816
  const { id } = req.params;
@@ -30520,32 +30638,6 @@ function startWorktreeReaper(deps = prodReaperDeps) {
30520
30638
  timer.unref();
30521
30639
  }
30522
30640
 
30523
- // src/services/session-result.ts
30524
- init_db();
30525
- import { randomUUID as randomUUID3 } from "node:crypto";
30526
- var WHITELIST = [
30527
- "projectId",
30528
- "specId",
30529
- "oldStage",
30530
- "newStage",
30531
- "commitSha",
30532
- "branch",
30533
- "prUrl",
30534
- "status",
30535
- "deviceId",
30536
- "author"
30537
- ];
30538
- async function recordSessionResult(input) {
30539
- const id = randomUUID3();
30540
- const data = { id };
30541
- for (const f of WHITELIST) if (input[f] !== void 0 && input[f] !== null) data[f] = input[f];
30542
- if (data.status === void 0) data.status = "done";
30543
- if (data.projectId === void 0) throw new Error("session result butuh projectId");
30544
- await prisma.sessionResult.create({ data });
30545
- await notifySynced("sessionResult", id);
30546
- return { id };
30547
- }
30548
-
30549
30641
  // src/routes/terminal.ts
30550
30642
  init_notifications();
30551
30643
  init_stage_machine();
@@ -40487,9 +40579,9 @@ async function help_default(app2) {
40487
40579
  return reply.code(201).send({ number: ticket.number, key, statusPath });
40488
40580
  });
40489
40581
  app2.get("/help/:slug/tickets/:key", async (req, reply) => {
40490
- const { slug, key } = req.params;
40582
+ const { key } = req.params;
40491
40583
  const t = await prisma.ticket.findFirst({
40492
- where: { projectId: slug, OR: [{ accessKeyHash: hashAccessKey(key) }, { shareToken: key }] }
40584
+ where: { OR: [{ accessKeyHash: hashAccessKey(key) }, { shareToken: key }] }
40493
40585
  });
40494
40586
  if (!t) return reply.code(404).send({ error: "not found" });
40495
40587
  let stage = null;
@@ -40594,7 +40686,7 @@ var view6 = (t) => ({
40594
40686
  attachmentCount: t._count?.attachments ?? 0,
40595
40687
  createdAt: t.createdAt.toISOString()
40596
40688
  });
40597
- async function tickets_default(app2) {
40689
+ async function tickets_default(app2, opts = {}) {
40598
40690
  app2.get("/tickets", async (req) => {
40599
40691
  const { project, status, q, page, limit } = req.query;
40600
40692
  const where = {};
@@ -40625,7 +40717,7 @@ async function tickets_default(app2) {
40625
40717
  shareToken = generateShareToken();
40626
40718
  await prisma.ticket.update({ where: { id }, data: { shareToken } });
40627
40719
  }
40628
- const base2 = `${req.protocol}://${req.headers.host ?? "localhost"}`;
40720
+ const base2 = opts.publicBase ?? `${req.protocol}://${req.headers.host ?? "localhost"}`;
40629
40721
  const publicStatusUrl = `${base2}/help/${encodeURIComponent(t.projectId)}/status/${shareToken}`;
40630
40722
  return {
40631
40723
  ...view6(t),
@@ -45143,22 +45235,31 @@ init_runtime();
45143
45235
 
45144
45236
  // src/services/ingress-policy.ts
45145
45237
  function origins(value, key) {
45146
- const out3 = /* @__PURE__ */ new Set();
45238
+ const out3 = [];
45147
45239
  for (const raw2 of value?.split(",") ?? []) {
45148
45240
  const input = raw2.trim();
45149
45241
  if (!input) continue;
45150
45242
  const parsed = new URL(input);
45151
45243
  if (!/^https?:$/.test(parsed.protocol) || parsed.username || parsed.password || parsed.pathname !== "/")
45152
45244
  throw new Error(`${key} harus berisi exact http(s) origin`);
45153
- out3.add(parsed.host.toLowerCase());
45245
+ out3.push({ host: parsed.host.toLowerCase(), base: `${parsed.protocol}//${parsed.host}` });
45154
45246
  }
45155
45247
  return out3;
45156
45248
  }
45157
45249
  function loadIngressPolicy(env) {
45158
- const publicHosts = origins(env.HANOMAN_PUBLIC_ORIGINS, "HANOMAN_PUBLIC_ORIGINS");
45159
- const controlHosts = origins(env.HANOMAN_CONTROL_ORIGINS, "HANOMAN_CONTROL_ORIGINS");
45250
+ const pub = origins(env.HANOMAN_PUBLIC_ORIGINS, "HANOMAN_PUBLIC_ORIGINS");
45251
+ const control = origins(env.HANOMAN_CONTROL_ORIGINS, "HANOMAN_CONTROL_ORIGINS");
45252
+ const publicHosts = new Set(pub.map((o) => o.host));
45253
+ const controlHosts = new Set(control.map((o) => o.host));
45160
45254
  for (const host2 of publicHosts) if (controlHosts.has(host2)) throw new Error("public dan control origin harus berbeda");
45161
- return { publicHosts, controlHosts, enforce: publicHosts.size > 0 || controlHosts.size > 0 };
45255
+ return {
45256
+ publicHosts,
45257
+ controlHosts,
45258
+ // SPEC-805 · origin publik pertama, lengkap dengan scheme-nya: satu-satunya basis sah untuk
45259
+ // link status yang dibagikan operator, yang selalu lahir di request ber-host control.
45260
+ publicBase: pub[0]?.base ?? null,
45261
+ enforce: publicHosts.size > 0 || controlHosts.size > 0
45262
+ };
45162
45263
  }
45163
45264
  function publicPath(method, path2) {
45164
45265
  if (method === "GET" && path2 === "/api/health") return true;
@@ -45170,7 +45271,8 @@ function classifyIngress(req, policy) {
45170
45271
  const host2 = req.host.trim().toLowerCase();
45171
45272
  const path2 = req.url.split("?")[0] ?? req.url;
45172
45273
  if (policy.publicHosts.has(host2)) return publicPath(req.method, path2) ? "public" : "denied";
45173
- if (policy.controlHosts.has(host2)) return publicPath(req.method, path2) && path2.startsWith("/api/help") ? "denied" : "control";
45274
+ if (policy.controlHosts.has(host2))
45275
+ return policy.publicBase && path2.startsWith("/api/help") ? "denied" : "control";
45174
45276
  return "denied";
45175
45277
  }
45176
45278
  function trustProxyFromEnv(env) {
@@ -45203,6 +45305,8 @@ function buildApp({ requireAuth = true, agentDocFile, env = process.env } = {})
45203
45305
  app2.addHook("onRequest", async (req, reply) => {
45204
45306
  const role = classifyIngress({ host: req.headers.host ?? "", method: req.method, url: req.url }, ingress);
45205
45307
  if (role === "denied") return reply.code(404).send({ error: "not found" });
45308
+ if (role === "control" && ingress.publicBase && req.url.startsWith("/help/"))
45309
+ return reply.redirect(`${ingress.publicBase}${req.url}`, 302);
45206
45310
  });
45207
45311
  app2.addContentTypeParser("application/json", { parseAs: "string" }, (_req, body, done) => {
45208
45312
  if (!body) return done(null, void 0);
@@ -45213,7 +45317,12 @@ function buildApp({ requireAuth = true, agentDocFile, env = process.env } = {})
45213
45317
  done(err, void 0);
45214
45318
  }
45215
45319
  });
45216
- app2.register(websocket, { options: { maxPayload: MAX_WS_MESSAGE_BYTES } });
45320
+ app2.register(websocket, {
45321
+ options: {
45322
+ maxPayload: MAX_WS_MESSAGE_BYTES,
45323
+ perMessageDeflate: { zlibDeflateOptions: { level: 6, memLevel: 7 }, concurrencyLimit: 10 }
45324
+ }
45325
+ });
45217
45326
  app2.addHook("onClose", async () => {
45218
45327
  await stopTelegramRuntime();
45219
45328
  detachAll();
@@ -45282,7 +45391,7 @@ function buildApp({ requireAuth = true, agentDocFile, env = process.env } = {})
45282
45391
  await api.register(session_history_default);
45283
45392
  await api.register(config_default);
45284
45393
  await api.register(help_default);
45285
- await api.register(tickets_default);
45394
+ await api.register(tickets_default, { publicBase: ingress.publicBase });
45286
45395
  await api.register(scheduler_default);
45287
45396
  await api.register(codex);
45288
45397
  await api.register(methods);
@@ -45975,6 +46084,7 @@ async function sweepAutoMerge(deps = prodAutoMergeDeps, now = /* @__PURE__ */ ne
45975
46084
  async function settleOne(c, deps, now) {
45976
46085
  const spec = await prisma.spec.findUnique({ where: { id: c.specId } });
45977
46086
  if (!spec || spec.stage !== "done") return false;
46087
+ if (spec.manualDone) return false;
45978
46088
  const project = await prisma.project.findUnique({ where: { id: spec.projectId } });
45979
46089
  if (!project) return false;
45980
46090
  const policy = resolveAutoMerge(
@@ -168,6 +168,7 @@ dan tak memakai agent token.
168
168
  | `GET /api/specs` | `backlog:read` | backlog. Filter: `project`, `source`, `q`, `stage`, `priority`, `startable=true`, `dateField=created\|started` + `from`/`to` (`YYYY-MM-DD`, inklusif), `page`, `limit`. |
169
169
  | `POST /api/specs` | `backlog:write` | buat backlog item — bentuk payload di §7. |
170
170
  | `PATCH /api/specs/:id` | `backlog:write` | ubah item; konten hanya selagi belum dimulai. |
171
+ | `POST /api/specs/:id/done` | `backlog:write` | tandai item **selesai** tanpa menjalankan sesi — untuk pekerjaan yang beres di luar hanoman. Body `{ reason?: string (≤280), confirm?: boolean }`, keduanya opsional; balasannya `Spec` yang sudah `stage:"done"`. Tak menjalankan maupun menghentikan sesi apa pun. |
171
172
  | `GET /api/specs/:id/docs` | `backlog:read` | dokumen yang ditulis sesi item itu. |
172
173
  | `GET /api/specs/:id/review` | `backlog:read` | diff hasil kerja sesi. |
173
174
  | `GET /api/projects/:id/docs` | `docs:read` | index Source of Truth project. |
@@ -271,7 +272,9 @@ capability-nya. Capability menjawab "boleh?", bukan "sebaiknya?".
271
272
  | `POST /api/lead/decisions` | putusannya bisa **menggerakkan sesi** (integrate ke main, menghentikan sesi) dan selalu melahirkan baris jejak permanen (ADR-0091/0098). |
272
273
 
273
274
  Perlakukan `POST /api/specs/:id/integrate`, `DELETE /api/specs/:id`, dan perubahan `stage` dengan
274
- disiplin yang sama: ketiganya mengubah sejarah git atau membuang pekerjaan.
275
+ disiplin yang sama: ketiganya mengubah sejarah git atau membuang pekerjaan. `POST /api/specs/:id/done`
276
+ tak menyentuh git, tetapi ia **menyatakan pekerjaan orang lain selesai** — pakai hanya bila kamu punya
277
+ buktinya, dan tulis buktinya di `reason`.
275
278
 
276
279
  **Preseden yang mengikat:** MCP server resmi (`hanoman mcp`, §13) sengaja **tak punya tool** untuk
277
280
  satu pun dari yang di atas — batasnya ada di katalog tool, bukan di token. Token yang punya
@@ -286,6 +289,7 @@ memanggilnya; jangan lakukan tanpa manusia.
286
289
  | `q` mencari di `id`, `title`, dan `objective` saja — ia **tak menyentuh `payload`** | untuk mencari isi brief/QA, ambil itemnya lalu baca `payload` sendiri |
287
290
  | `id` dan `stage` yang kamu sertakan di `POST /api/specs` **dibuang diam-diam** — tak ada galat | `id` diterbitkan server (`SPEC-nnn` berikutnya), `stage` selalu mulai `brainstorming`. Untuk mengubah stage pakai `PATCH /api/specs/:id`, dan ia hanya boleh **mundur** (ADR-0027) |
288
291
  | **`GET /api/specs/:id` tidak ada** | `GET /api/specs?q=SPEC-489` lalu cocokkan `id` **persis** — `q` itu substring, jadi ia bisa mengembalikan lebih dari satu |
292
+ | `POST /api/specs/:id/done` menjawab **409 `{"error":"confirm-required","session":{...}}`** — itu bukan penolakan, melainkan pemberitahuan bahwa masih ada **sesi hidup** untuk item itu | putuskan dulu apakah sesi itu memang sudah tak relevan; bila ya, kirim ulang dengan `{"confirm": true}`. Sesinya **tidak** ikut dihentikan — tutup sesinya sendiri bila perlu. 409 `{"error":"backlog item sudah selesai"}` berarti item itu memang sudah `done` |
289
293
  | daftar mengembalikan amplop `{ items, total, page, pageSize }` | jangan perlakukan responsnya sebagai array |
290
294
  | tanpa `limit`, daftar mengembalikan **seluruh** item dalam satu halaman | kirim `limit` untuk backlog besar |
291
295
  | **`GET /api/notifications` adalah pengecualiannya**: tanpa `limit` ia mengembalikan **50 teratas**, bukan seluruhnya — angka penuhnya ada di `total` | kirim `page`/`limit` bila kamu butuh riwayat lama; jangan simpulkan `items.length` = seluruh notifikasi |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hanoman",
3
- "version": "0.1.40",
3
+ "version": "0.1.42",
4
4
  "description": "Orchestrator + dashboard workflow docs-driven untuk sesi Claude Code / Codex",
5
5
  "type": "module",
6
6
  "repository": {
@@ -0,0 +1,9 @@
1
+ -- SPEC-804 · ADR-0120 · jejak penandaan selesai manual sebagai kolom.
2
+ --
3
+ -- Ditulis tangan (bukan `migrate dev`): worktree tetangga membuat `migrate dev` me-reset DB saat
4
+ -- ada drift. ADITIF murni — satu kolom NULLABLE tanpa default, tak ada tabel diredefinisi.
5
+ --
6
+ -- TANPA backfill, sengaja: sebelum spec ini jalur "tandai selesai manual" memang tak ada, jadi
7
+ -- tak ada stempel lama yang bisa dipulihkan. Item lama tetap NULL = "selesai lewat sesi / tak
8
+ -- diketahui", dan itu jawaban yang jujur.
9
+ ALTER TABLE "Spec" ADD COLUMN "manualDone" JSONB;
@@ -63,6 +63,12 @@ model Spec {
63
63
  // site adalah kelas bug SPEC-431/448/475. Tulis-sekali: reopen lalu selesai lagi tak
64
64
  // memindahkannya, cermin `startedAt` = mulai pertama (ADR-0090).
65
65
  doneAt DateTime?
66
+ // SPEC-804 · ADR-0120 · jejak penandaan selesai MANUAL: { at, by, reason? }. Satu kolom, bukan
67
+ // tiga skalar — ketiganya satu peristiwa, dan tiga kolom nullable bisa drift tanpa tipe yang
68
+ // memaksanya konsisten (kelas gagal-senyap ADR-0090/0093/0094/0105). null = item ini tak pernah
69
+ // ditandai manual. Ditimpa tiap penandaan berikutnya: ia menjelaskan keadaan yang BERLAKU;
70
+ // riwayat transisi stage tinggal di SessionResult (ADR-0047). Ikut FIELDS.spec sync.
71
+ manualDone Json?
66
72
  // SPEC-447 · ADR-0093 · id spec lain yang harus SELESAI & commit-nya sudah ada di branch basis
67
73
  // sebelum item ini boleh diluncurkan. Array JSON of string; null/[] = tak bergantung apa pun.
68
74
  // Sengaja kolom, bukan tabel join: SQLite melarang scalar list, `Json` sudah dipakai `payload`,