hanoman 0.1.40 → 0.1.41

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.41",
3
+ "sha": "5ef26fe",
4
+ "builtAt": "2026-08-15T14:26:22.718Z"
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");
@@ -10531,6 +10549,7 @@ __export(sync_exports, {
10531
10549
  __DATE_FIELDS: () => __DATE_FIELDS,
10532
10550
  __FIELDS: () => __FIELDS,
10533
10551
  __FIELDS_FOR_TEST: () => __FIELDS_FOR_TEST,
10552
+ __JSON_FIELDS: () => __JSON_FIELDS,
10534
10553
  applyPush: () => applyPush,
10535
10554
  backfillFeed: () => backfillFeed,
10536
10555
  consumeTombstoneOnRecreate: () => consumeTombstoneOnRecreate,
@@ -10774,7 +10793,7 @@ async function upsertLocal(entity, id, version2, data) {
10774
10793
  function setAcceptedHook(hook) {
10775
10794
  onAccepted = hook;
10776
10795
  }
10777
- var SYNCED, DELEGATE, FIELDS, DATE_FIELDS, PARENTS, __FIELDS, __DATE_FIELDS, NUMBER_FIELDS, BOOLEAN_FIELDS, JSON_FIELDS, onAccepted, __FIELDS_FOR_TEST;
10796
+ var SYNCED, DELEGATE, FIELDS, DATE_FIELDS, PARENTS, __FIELDS, __DATE_FIELDS, NUMBER_FIELDS, BOOLEAN_FIELDS, JSON_FIELDS, __JSON_FIELDS, onAccepted, __FIELDS_FOR_TEST;
10778
10797
  var init_sync = __esm({
10779
10798
  "src/services/sync.ts"() {
10780
10799
  "use strict";
@@ -10806,7 +10825,10 @@ var init_sync = __esm({
10806
10825
  // TETAP berhasil, jadi kolom yang terlewat di sini mendarat sebagai null palsu di tiap client
10807
10826
  // tanpa satu pun error (kelas gagal-senyap ADR-0090/0093/0094/0105). BUKAN DATE_FIELDS —
10808
10827
  // `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"],
10828
+ // SPEC-804 · ADR-0120 · manualDone ikut menyeberang: "item ini ditandai selesai manusia" adalah
10829
+ // bagian keadaan yang harus dilihat sama oleh semua mesin — di antaranya gerbang auto-merge.
10830
+ // BUKAN DATE_FIELDS, alasan yang sama dengan sourceHistory.
10831
+ spec: ["projectId", "title", "source", "stage", "priority", "author", "objective", "payload", "branchFrom", "baseSha", "headSha", "dependsOn", "sourceHistory", "manualDone", "createdAt", "startedAt", "doneAt", "updatedAt"],
10810
10832
  vps: ["name", "host", "port", "user", "health", "audit", "hardened", "lastSeenAt", "lastAuditAt", "updatedAt"],
10811
10833
  sessionResult: ["projectId", "specId", "oldStage", "newStage", "commitSha", "branch", "prUrl", "status", "deviceId", "author", "createdAt", "updatedAt"],
10812
10834
  // SPEC-268 · ADR-0066 · metadata tiket (lampiran biner tak disync). accessKeyHash wajib
@@ -10874,12 +10896,14 @@ var init_sync = __esm({
10874
10896
  "spec:payload",
10875
10897
  "spec:dependsOn",
10876
10898
  "spec:sourceHistory",
10899
+ "spec:manualDone",
10877
10900
  "vps:health",
10878
10901
  "vps:audit",
10879
10902
  "customAgent:tools",
10880
10903
  "customAgent:mentions",
10881
10904
  "githubIssue:labels"
10882
10905
  ]);
10906
+ __JSON_FIELDS = JSON_FIELDS;
10883
10907
  __FIELDS_FOR_TEST = FIELDS;
10884
10908
  }
10885
10909
  });
@@ -25773,6 +25797,65 @@ function appendSourceHistory(current, entry) {
25773
25797
 
25774
25798
  // src/routes/specs.ts
25775
25799
  init_notifications();
25800
+
25801
+ // src/services/spec-complete.ts
25802
+ init_db();
25803
+ init_notifications();
25804
+
25805
+ // src/services/session-result.ts
25806
+ init_db();
25807
+ import { randomUUID as randomUUID3 } from "node:crypto";
25808
+ var WHITELIST = [
25809
+ "projectId",
25810
+ "specId",
25811
+ "oldStage",
25812
+ "newStage",
25813
+ "commitSha",
25814
+ "branch",
25815
+ "prUrl",
25816
+ "status",
25817
+ "deviceId",
25818
+ "author"
25819
+ ];
25820
+ async function recordSessionResult(input) {
25821
+ const id = randomUUID3();
25822
+ const data = { id };
25823
+ for (const f of WHITELIST) if (input[f] !== void 0 && input[f] !== null) data[f] = input[f];
25824
+ if (data.status === void 0) data.status = "done";
25825
+ if (data.projectId === void 0) throw new Error("session result butuh projectId");
25826
+ await prisma.sessionResult.create({ data });
25827
+ await notifySynced("sessionResult", id);
25828
+ return { id };
25829
+ }
25830
+
25831
+ // src/services/spec-complete.ts
25832
+ async function completeSpecManually(spec, input) {
25833
+ const at = input.at ?? /* @__PURE__ */ new Date();
25834
+ const manualDone = {
25835
+ at: at.toISOString(),
25836
+ by: input.by,
25837
+ ...input.reason ? { reason: input.reason } : {}
25838
+ };
25839
+ const { count } = await prisma.spec.updateMany({
25840
+ where: { id: spec.id, stage: { not: "done" } },
25841
+ data: { stage: "done", manualDone }
25842
+ });
25843
+ if (count === 0) return { ok: false };
25844
+ await recordCompletion(spec.id, spec.title, spec.projectId);
25845
+ await recordSessionResult({
25846
+ projectId: spec.projectId,
25847
+ specId: spec.id,
25848
+ oldStage: spec.stage,
25849
+ newStage: "done",
25850
+ status: "done",
25851
+ author: input.by
25852
+ }).catch(() => {
25853
+ });
25854
+ await notifySynced("spec", spec.id);
25855
+ return { ok: true, spec: await prisma.spec.findUnique({ where: { id: spec.id } }) };
25856
+ }
25857
+
25858
+ // src/routes/specs.ts
25776
25859
  init_stage_machine();
25777
25860
 
25778
25861
  // src/services/stage-artifacts.ts
@@ -28693,6 +28776,23 @@ ${item.context}` : item.context;
28693
28776
  await notifySynced("spec", id);
28694
28777
  return updated;
28695
28778
  });
28779
+ app2.post("/specs/:id/done", async (req, reply) => {
28780
+ const { id } = req.params;
28781
+ const parsed = zMarkSpecDone.safeParse(req.body ?? {});
28782
+ if (!parsed.success) return reply.code(400).send({ error: parsed.error.flatten() });
28783
+ const spec = await prisma.spec.findUnique({ where: { id } });
28784
+ if (!spec) return reply.code(404).send({ error: "not found" });
28785
+ if (spec.stage === "done") return reply.code(409).send({ error: "backlog item sudah selesai" });
28786
+ const live = listSessions().find((s2) => s2.specId === id && !s2.exited);
28787
+ if (live && parsed.data.confirm !== true)
28788
+ return reply.code(409).send({ error: "confirm-required", session: { id: live.id, agent: live.agent } });
28789
+ const res = await completeSpecManually(spec, {
28790
+ by: req.user?.email ?? "system",
28791
+ reason: parsed.data.reason || void 0
28792
+ });
28793
+ if (!res.ok) return reply.code(409).send({ error: "backlog item sudah selesai" });
28794
+ return res.spec;
28795
+ });
28696
28796
  app2.get("/specs/:id/docs", async (req) => ({ files: await listSpecDocs(req.params.id) }));
28697
28797
  app2.get("/specs/:id/docs/*", async (req, reply) => {
28698
28798
  const { id } = req.params;
@@ -30520,32 +30620,6 @@ function startWorktreeReaper(deps = prodReaperDeps) {
30520
30620
  timer.unref();
30521
30621
  }
30522
30622
 
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
30623
  // src/routes/terminal.ts
30550
30624
  init_notifications();
30551
30625
  init_stage_machine();
@@ -40487,9 +40561,9 @@ async function help_default(app2) {
40487
40561
  return reply.code(201).send({ number: ticket.number, key, statusPath });
40488
40562
  });
40489
40563
  app2.get("/help/:slug/tickets/:key", async (req, reply) => {
40490
- const { slug, key } = req.params;
40564
+ const { key } = req.params;
40491
40565
  const t = await prisma.ticket.findFirst({
40492
- where: { projectId: slug, OR: [{ accessKeyHash: hashAccessKey(key) }, { shareToken: key }] }
40566
+ where: { OR: [{ accessKeyHash: hashAccessKey(key) }, { shareToken: key }] }
40493
40567
  });
40494
40568
  if (!t) return reply.code(404).send({ error: "not found" });
40495
40569
  let stage = null;
@@ -40594,7 +40668,7 @@ var view6 = (t) => ({
40594
40668
  attachmentCount: t._count?.attachments ?? 0,
40595
40669
  createdAt: t.createdAt.toISOString()
40596
40670
  });
40597
- async function tickets_default(app2) {
40671
+ async function tickets_default(app2, opts = {}) {
40598
40672
  app2.get("/tickets", async (req) => {
40599
40673
  const { project, status, q, page, limit } = req.query;
40600
40674
  const where = {};
@@ -40625,7 +40699,7 @@ async function tickets_default(app2) {
40625
40699
  shareToken = generateShareToken();
40626
40700
  await prisma.ticket.update({ where: { id }, data: { shareToken } });
40627
40701
  }
40628
- const base2 = `${req.protocol}://${req.headers.host ?? "localhost"}`;
40702
+ const base2 = opts.publicBase ?? `${req.protocol}://${req.headers.host ?? "localhost"}`;
40629
40703
  const publicStatusUrl = `${base2}/help/${encodeURIComponent(t.projectId)}/status/${shareToken}`;
40630
40704
  return {
40631
40705
  ...view6(t),
@@ -45143,22 +45217,31 @@ init_runtime();
45143
45217
 
45144
45218
  // src/services/ingress-policy.ts
45145
45219
  function origins(value, key) {
45146
- const out3 = /* @__PURE__ */ new Set();
45220
+ const out3 = [];
45147
45221
  for (const raw2 of value?.split(",") ?? []) {
45148
45222
  const input = raw2.trim();
45149
45223
  if (!input) continue;
45150
45224
  const parsed = new URL(input);
45151
45225
  if (!/^https?:$/.test(parsed.protocol) || parsed.username || parsed.password || parsed.pathname !== "/")
45152
45226
  throw new Error(`${key} harus berisi exact http(s) origin`);
45153
- out3.add(parsed.host.toLowerCase());
45227
+ out3.push({ host: parsed.host.toLowerCase(), base: `${parsed.protocol}//${parsed.host}` });
45154
45228
  }
45155
45229
  return out3;
45156
45230
  }
45157
45231
  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");
45232
+ const pub = origins(env.HANOMAN_PUBLIC_ORIGINS, "HANOMAN_PUBLIC_ORIGINS");
45233
+ const control = origins(env.HANOMAN_CONTROL_ORIGINS, "HANOMAN_CONTROL_ORIGINS");
45234
+ const publicHosts = new Set(pub.map((o) => o.host));
45235
+ const controlHosts = new Set(control.map((o) => o.host));
45160
45236
  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 };
45237
+ return {
45238
+ publicHosts,
45239
+ controlHosts,
45240
+ // SPEC-805 · origin publik pertama, lengkap dengan scheme-nya: satu-satunya basis sah untuk
45241
+ // link status yang dibagikan operator, yang selalu lahir di request ber-host control.
45242
+ publicBase: pub[0]?.base ?? null,
45243
+ enforce: publicHosts.size > 0 || controlHosts.size > 0
45244
+ };
45162
45245
  }
45163
45246
  function publicPath(method, path2) {
45164
45247
  if (method === "GET" && path2 === "/api/health") return true;
@@ -45170,7 +45253,8 @@ function classifyIngress(req, policy) {
45170
45253
  const host2 = req.host.trim().toLowerCase();
45171
45254
  const path2 = req.url.split("?")[0] ?? req.url;
45172
45255
  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";
45256
+ if (policy.controlHosts.has(host2))
45257
+ return policy.publicBase && path2.startsWith("/api/help") ? "denied" : "control";
45174
45258
  return "denied";
45175
45259
  }
45176
45260
  function trustProxyFromEnv(env) {
@@ -45203,6 +45287,8 @@ function buildApp({ requireAuth = true, agentDocFile, env = process.env } = {})
45203
45287
  app2.addHook("onRequest", async (req, reply) => {
45204
45288
  const role = classifyIngress({ host: req.headers.host ?? "", method: req.method, url: req.url }, ingress);
45205
45289
  if (role === "denied") return reply.code(404).send({ error: "not found" });
45290
+ if (role === "control" && ingress.publicBase && req.url.startsWith("/help/"))
45291
+ return reply.redirect(`${ingress.publicBase}${req.url}`, 302);
45206
45292
  });
45207
45293
  app2.addContentTypeParser("application/json", { parseAs: "string" }, (_req, body, done) => {
45208
45294
  if (!body) return done(null, void 0);
@@ -45282,7 +45368,7 @@ function buildApp({ requireAuth = true, agentDocFile, env = process.env } = {})
45282
45368
  await api.register(session_history_default);
45283
45369
  await api.register(config_default);
45284
45370
  await api.register(help_default);
45285
- await api.register(tickets_default);
45371
+ await api.register(tickets_default, { publicBase: ingress.publicBase });
45286
45372
  await api.register(scheduler_default);
45287
45373
  await api.register(codex);
45288
45374
  await api.register(methods);
@@ -45975,6 +46061,7 @@ async function sweepAutoMerge(deps = prodAutoMergeDeps, now = /* @__PURE__ */ ne
45975
46061
  async function settleOne(c, deps, now) {
45976
46062
  const spec = await prisma.spec.findUnique({ where: { id: c.specId } });
45977
46063
  if (!spec || spec.stage !== "done") return false;
46064
+ if (spec.manualDone) return false;
45978
46065
  const project = await prisma.project.findUnique({ where: { id: spec.projectId } });
45979
46066
  if (!project) return false;
45980
46067
  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.41",
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`,