hanoman 0.1.7 → 0.1.8

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.7",
3
- "sha": "33ae716",
4
- "builtAt": "2026-07-31T15:10:07.810Z"
2
+ "version": "0.1.8",
3
+ "sha": "a749553",
4
+ "builtAt": "2026-07-31T17:13:08.743Z"
5
5
  }
package/dist/cli.js CHANGED
@@ -4357,7 +4357,7 @@ var init_enums = __esm({
4357
4357
  });
4358
4358
 
4359
4359
  // ../shared/src/entities.ts
4360
- var zProject, zBriefPayload, zQaPayload, zGoalPayload, zSpec, NOTIFY_SOUNDS, zCodex, CODEX_DEFAULTS, zSourceCommon, zScheduler, SCHEDULER_DEFAULTS, zGoal, GOAL_DEFAULTS, zConflict, CONFLICT_DEFAULTS, zLeadEngine, zLead, LEAD_DEFAULTS, zSetting, zNotification, zDocFile, zDeviceTokenView;
4360
+ var zProject, zBriefPayload, zQaPayload, zGoalPayload, zSpecBlocker, zSpec, NOTIFY_SOUNDS, zCodex, CODEX_DEFAULTS, zSourceCommon, zScheduler, SCHEDULER_DEFAULTS, zGoal, GOAL_DEFAULTS, zConflict, CONFLICT_DEFAULTS, zLeadEngine, zLead, LEAD_DEFAULTS, zSetting, zNotification, zDocFile, zDeviceTokenView;
4361
4361
  var init_entities = __esm({
4362
4362
  "../shared/src/entities.ts"() {
4363
4363
  "use strict";
@@ -4400,6 +4400,10 @@ var init_entities = __esm({
4400
4400
  constraints: external_exports.string(),
4401
4401
  priority: zPriority
4402
4402
  });
4403
+ zSpecBlocker = external_exports.object({
4404
+ id: external_exports.string(),
4405
+ reason: external_exports.enum(["missing", "unfinished", "unmerged"])
4406
+ });
4403
4407
  zSpec = external_exports.object({
4404
4408
  id: external_exports.string(),
4405
4409
  projectId: external_exports.string(),
@@ -4418,7 +4422,13 @@ var init_entities = __esm({
4418
4422
  // SPEC-408 · ADR-0090 · stempel waktu backlog (ISO string di wire — kolom DateTime di DB).
4419
4423
  // `startedAt` null = belum pernah dikerjakan; ia tak pernah ditulis ulang saat sesi dilanjutkan.
4420
4424
  createdAt: external_exports.string(),
4421
- startedAt: external_exports.string().nullable()
4425
+ startedAt: external_exports.string().nullable(),
4426
+ // SPEC-447 · ADR-0093 · id backlog yang harus selesai & ter-merge lebih dulu. Server selalu
4427
+ // menormalkannya ke array (kolom DB-nya `Json?`); `.default([])` menjaga respons lama.
4428
+ dependsOn: external_exports.array(external_exports.string()).default([]),
4429
+ // Turunan (bukan kolom): dihitung `liveSpecs` dari stage dependency + git. Klien tak pernah
4430
+ // mengirimkannya — `.default([])` supaya bentuk lama tetap parse.
4431
+ blockedBy: external_exports.array(zSpecBlocker).default([])
4422
4432
  });
4423
4433
  NOTIFY_SOUNDS = [
4424
4434
  "off",
@@ -4758,7 +4768,9 @@ var init_dto = __esm({
4758
4768
  title: external_exports.string().min(1),
4759
4769
  priority: zPriority,
4760
4770
  payload: external_exports.union([zBriefPayload, zQaPayload, zGoalPayload]),
4761
- branchFrom: external_exports.string().min(1).optional()
4771
+ branchFrom: external_exports.string().min(1).optional(),
4772
+ // SPEC-447 · ADR-0093 · divalidasi server (id ada / satu project / bukan diri sendiri / non-siklus).
4773
+ dependsOn: external_exports.array(external_exports.string()).optional()
4762
4774
  }).superRefine((o, ctx) => {
4763
4775
  const shape = "severity" in o.payload ? "qa" : "goal" in o.payload ? "goal" : "brief";
4764
4776
  const want = o.source === "qa" ? "qa" : o.source === "goal" ? "goal" : "brief";
@@ -4772,8 +4784,12 @@ var init_dto = __esm({
4772
4784
  // SPEC-186 · edit konten selagi item belum dimulai. Ditolak server bila sudah mulai.
4773
4785
  title: external_exports.string().min(1).optional(),
4774
4786
  priority: zPriority.optional(),
4775
- payload: external_exports.union([zBriefPayload, zQaPayload, zGoalPayload]).optional()
4787
+ payload: external_exports.union([zBriefPayload, zQaPayload, zGoalPayload]).optional(),
4776
4788
  // SPEC-407 · +goal
4789
+ // SPEC-447 · ADR-0093 · SENGAJA di luar gerbang `editingContent` (SPEC-186): gerbang itu
4790
+ // melindungi konten yang sudah jadi dasar kerja sesi berjalan, sedangkan dependsOn hanya
4791
+ // menggerbangi peluncuran BERIKUTNYA. `[]` = kosongkan.
4792
+ dependsOn: external_exports.array(external_exports.string()).optional()
4777
4793
  });
4778
4794
  zIntegrate = external_exports.object({
4779
4795
  op: external_exports.enum(["merge", "rebase"]),
@@ -4969,7 +4985,10 @@ var init_dto = __esm({
4969
4985
  goal: external_exports.boolean().optional(),
4970
4986
  goalCondition: external_exports.string().max(4e3).optional(),
4971
4987
  agent: zAgent.optional(),
4972
- verifyScope: zVerifyScope.optional()
4988
+ verifyScope: zVerifyScope.optional(),
4989
+ // SPEC-447 · ADR-0093 — lewati gerbang dependency. Hanya jalur manusia; UI hanya
4990
+ // mengirimkannya sesudah operator melihat daftar pemblokirnya.
4991
+ force: external_exports.boolean().optional()
4973
4992
  })
4974
4993
  ]);
4975
4994
  zDocFileContent = external_exports.object({ content: external_exports.string() });
package/dist/server.js CHANGED
@@ -4182,7 +4182,7 @@ function cmpVersion(a, b) {
4182
4182
  }
4183
4183
  return 0;
4184
4184
  }
4185
- var zProject, zBriefPayload, zQaPayload, zGoalPayload, zSpec, NOTIFY_SOUNDS, E_5_6, E_LUNA, E_BASE, CODEX_MODELS, RETIRED_CODEX_MODELS, zCodex, CODEX_DEFAULTS, zSourceCommon, zScheduler, SCHEDULER_DEFAULTS, zGoal, GOAL_DEFAULTS, zConflict, CONFLICT_DEFAULTS, zLeadEngine, zLead, LEAD_DEFAULTS, zSetting, zNotification, zDocFile, zDeviceTokenView;
4185
+ var zProject, zBriefPayload, zQaPayload, zGoalPayload, zSpecBlocker, zSpec, NOTIFY_SOUNDS, E_5_6, E_LUNA, E_BASE, CODEX_MODELS, RETIRED_CODEX_MODELS, zCodex, CODEX_DEFAULTS, zSourceCommon, zScheduler, SCHEDULER_DEFAULTS, zGoal, GOAL_DEFAULTS, zConflict, CONFLICT_DEFAULTS, zLeadEngine, zLead, LEAD_DEFAULTS, zSetting, zNotification, zDocFile, zDeviceTokenView;
4186
4186
  var init_entities = __esm({
4187
4187
  "../shared/src/entities.ts"() {
4188
4188
  "use strict";
@@ -4225,6 +4225,10 @@ var init_entities = __esm({
4225
4225
  constraints: external_exports.string(),
4226
4226
  priority: zPriority
4227
4227
  });
4228
+ zSpecBlocker = external_exports.object({
4229
+ id: external_exports.string(),
4230
+ reason: external_exports.enum(["missing", "unfinished", "unmerged"])
4231
+ });
4228
4232
  zSpec = external_exports.object({
4229
4233
  id: external_exports.string(),
4230
4234
  projectId: external_exports.string(),
@@ -4243,7 +4247,13 @@ var init_entities = __esm({
4243
4247
  // SPEC-408 · ADR-0090 · stempel waktu backlog (ISO string di wire — kolom DateTime di DB).
4244
4248
  // `startedAt` null = belum pernah dikerjakan; ia tak pernah ditulis ulang saat sesi dilanjutkan.
4245
4249
  createdAt: external_exports.string(),
4246
- startedAt: external_exports.string().nullable()
4250
+ startedAt: external_exports.string().nullable(),
4251
+ // SPEC-447 · ADR-0093 · id backlog yang harus selesai & ter-merge lebih dulu. Server selalu
4252
+ // menormalkannya ke array (kolom DB-nya `Json?`); `.default([])` menjaga respons lama.
4253
+ dependsOn: external_exports.array(external_exports.string()).default([]),
4254
+ // Turunan (bukan kolom): dihitung `liveSpecs` dari stage dependency + git. Klien tak pernah
4255
+ // mengirimkannya — `.default([])` supaya bentuk lama tetap parse.
4256
+ blockedBy: external_exports.array(zSpecBlocker).default([])
4247
4257
  });
4248
4258
  NOTIFY_SOUNDS = [
4249
4259
  "off",
@@ -4650,7 +4660,9 @@ var init_dto = __esm({
4650
4660
  title: external_exports.string().min(1),
4651
4661
  priority: zPriority,
4652
4662
  payload: external_exports.union([zBriefPayload, zQaPayload, zGoalPayload]),
4653
- branchFrom: external_exports.string().min(1).optional()
4663
+ branchFrom: external_exports.string().min(1).optional(),
4664
+ // SPEC-447 · ADR-0093 · divalidasi server (id ada / satu project / bukan diri sendiri / non-siklus).
4665
+ dependsOn: external_exports.array(external_exports.string()).optional()
4654
4666
  }).superRefine((o, ctx) => {
4655
4667
  const shape = "severity" in o.payload ? "qa" : "goal" in o.payload ? "goal" : "brief";
4656
4668
  const want = o.source === "qa" ? "qa" : o.source === "goal" ? "goal" : "brief";
@@ -4664,8 +4676,12 @@ var init_dto = __esm({
4664
4676
  // SPEC-186 · edit konten selagi item belum dimulai. Ditolak server bila sudah mulai.
4665
4677
  title: external_exports.string().min(1).optional(),
4666
4678
  priority: zPriority.optional(),
4667
- payload: external_exports.union([zBriefPayload, zQaPayload, zGoalPayload]).optional()
4679
+ payload: external_exports.union([zBriefPayload, zQaPayload, zGoalPayload]).optional(),
4668
4680
  // SPEC-407 · +goal
4681
+ // SPEC-447 · ADR-0093 · SENGAJA di luar gerbang `editingContent` (SPEC-186): gerbang itu
4682
+ // melindungi konten yang sudah jadi dasar kerja sesi berjalan, sedangkan dependsOn hanya
4683
+ // menggerbangi peluncuran BERIKUTNYA. `[]` = kosongkan.
4684
+ dependsOn: external_exports.array(external_exports.string()).optional()
4669
4685
  });
4670
4686
  zIntegrate = external_exports.object({
4671
4687
  op: external_exports.enum(["merge", "rebase"]),
@@ -4861,7 +4877,10 @@ var init_dto = __esm({
4861
4877
  goal: external_exports.boolean().optional(),
4862
4878
  goalCondition: external_exports.string().max(4e3).optional(),
4863
4879
  agent: zAgent.optional(),
4864
- verifyScope: zVerifyScope.optional()
4880
+ verifyScope: zVerifyScope.optional(),
4881
+ // SPEC-447 · ADR-0093 — lewati gerbang dependency. Hanya jalur manusia; UI hanya
4882
+ // mengirimkannya sesudah operator melihat daftar pemblokirnya.
4883
+ force: external_exports.boolean().optional()
4865
4884
  })
4866
4885
  ]);
4867
4886
  zDocFileContent = external_exports.object({ content: external_exports.string() });
@@ -5989,6 +6008,17 @@ var init_git = __esm({
5989
6008
  { cwd: repo, encoding: "utf8" }
5990
6009
  );
5991
6010
  return r.status === 0 ? r.stdout.trim() : null;
6011
+ },
6012
+ // SPEC-447 · `git merge-base --is-ancestor A B` = exit 0 (ya) / 1 (tidak) / lainnya (error).
6013
+ // `--end-of-options` menjaga ADR-0032: sha & ref datang dari DB/kolom, jangan sampai terbaca
6014
+ // sebagai flag. Ref yang tak resolve membuat git exit 128 → dibaca sebagai "belum" (fail-closed).
6015
+ isAncestor: (repo, sha, ref) => {
6016
+ const r = spawnSync(
6017
+ "git",
6018
+ ["merge-base", "--is-ancestor", "--end-of-options", sha, ref],
6019
+ { cwd: repo, encoding: "utf8" }
6020
+ );
6021
+ return r.status === 0;
5992
6022
  }
5993
6023
  };
5994
6024
  }
@@ -7000,6 +7030,7 @@ var init_rename_project = __esm({
7000
7030
  var sync_exports = {};
7001
7031
  __export(sync_exports, {
7002
7032
  SYNCED: () => SYNCED,
7033
+ __FIELDS_FOR_TEST: () => __FIELDS_FOR_TEST,
7003
7034
  applyPush: () => applyPush,
7004
7035
  backfillFeed: () => backfillFeed,
7005
7036
  isEntity: () => isEntity,
@@ -7137,7 +7168,7 @@ async function upsertLocal(entity, id, version, data) {
7137
7168
  function setAcceptedHook(hook) {
7138
7169
  onAccepted = hook;
7139
7170
  }
7140
- var SYNCED, DELEGATE, FIELDS, DATE_FIELDS, onAccepted;
7171
+ var SYNCED, DELEGATE, FIELDS, DATE_FIELDS, onAccepted, __FIELDS_FOR_TEST;
7141
7172
  var init_sync = __esm({
7142
7173
  "src/services/sync.ts"() {
7143
7174
  "use strict";
@@ -7156,7 +7187,9 @@ var init_sync = __esm({
7156
7187
  project: ["name", "desc", "kind", "stack", "gitRemote", "updatedAt"],
7157
7188
  // SPEC-408 · ADR-0090 · createdAt/startedAt ikut menyeberang — sejajar baseSha/headSha. Tanpa
7158
7189
  // ini spec asal-hub mendapat createdAt lokal palsu di tiap client (kolom NOT NULL ber-default).
7159
- spec: ["projectId", "title", "source", "stage", "priority", "author", "objective", "payload", "branchFrom", "baseSha", "headSha", "createdAt", "startedAt", "updatedAt"],
7190
+ // SPEC-447 · ADR-0093 · dependsOn ikut juga: tanpa itu client tak tahu urutannya dan akan
7191
+ // meluncurkan pekerjaan yang di hub terblokir. Bukan DATE_FIELDS — nilainya array string.
7192
+ spec: ["projectId", "title", "source", "stage", "priority", "author", "objective", "payload", "branchFrom", "baseSha", "headSha", "dependsOn", "createdAt", "startedAt", "updatedAt"],
7160
7193
  vps: ["name", "host", "port", "user", "health", "audit", "hardened", "lastSeenAt", "lastAuditAt", "updatedAt"],
7161
7194
  sessionResult: ["projectId", "specId", "oldStage", "newStage", "commitSha", "branch", "prUrl", "status", "deviceId", "author", "createdAt", "updatedAt"],
7162
7195
  // SPEC-268 · ADR-0066 · metadata tiket (lampiran biner tak disync). accessKeyHash wajib
@@ -7173,6 +7206,7 @@ var init_sync = __esm({
7173
7206
  ticket: ["createdAt", "updatedAt"],
7174
7207
  ticketAttachment: ["createdAt", "updatedAt"]
7175
7208
  };
7209
+ __FIELDS_FOR_TEST = FIELDS;
7176
7210
  }
7177
7211
  });
7178
7212
 
@@ -15360,6 +15394,128 @@ async function readEscalation(specId, sessions = listSessions()) {
15360
15394
  };
15361
15395
  }
15362
15396
 
15397
+ // src/services/spec-deps.ts
15398
+ init_db();
15399
+ init_src2();
15400
+ var REASON_LABEL = {
15401
+ missing: "tak ditemukan",
15402
+ unfinished: "belum selesai",
15403
+ unmerged: "belum ter-merge"
15404
+ };
15405
+ function dependsOnOf(spec) {
15406
+ const v = spec.dependsOn;
15407
+ if (!Array.isArray(v)) return [];
15408
+ const out3 = [];
15409
+ for (const x of v) if (typeof x === "string" && x !== "" && !out3.includes(x)) out3.push(x);
15410
+ return out3;
15411
+ }
15412
+ function blockersFor(spec, deps, isMerged) {
15413
+ const ids = dependsOnOf(spec);
15414
+ if (ids.length === 0) return [];
15415
+ const base = spec.branchFrom ?? "HEAD";
15416
+ const out3 = [];
15417
+ for (const id of ids) {
15418
+ const d = deps.get(id);
15419
+ if (!d) {
15420
+ out3.push({ id, reason: "missing" });
15421
+ continue;
15422
+ }
15423
+ if (d.stage !== "done") {
15424
+ out3.push({ id, reason: "unfinished" });
15425
+ continue;
15426
+ }
15427
+ if (d.headSha && !isMerged(d.headSha, base)) out3.push({ id, reason: "unmerged" });
15428
+ }
15429
+ return out3;
15430
+ }
15431
+ function blockedNote(bl) {
15432
+ return `menunggu ${bl.map((b) => `${b.id} (${REASON_LABEL[b.reason]})`).join(", ")}`;
15433
+ }
15434
+ function reaches(edges, from, target2) {
15435
+ const seen = /* @__PURE__ */ new Set();
15436
+ const stack = [...from];
15437
+ while (stack.length) {
15438
+ const cur = stack.pop();
15439
+ if (cur === target2) return true;
15440
+ if (seen.has(cur)) continue;
15441
+ seen.add(cur);
15442
+ for (const n of edges.get(cur) ?? []) stack.push(n);
15443
+ }
15444
+ return false;
15445
+ }
15446
+ var TTL_MS = 15e3;
15447
+ var mergeCache = /* @__PURE__ */ new Map();
15448
+ function mergedInto(repoDir, sha, baseRef) {
15449
+ const key = `${repoDir} ${sha} ${baseRef}`;
15450
+ const hit = mergeCache.get(key);
15451
+ const now = Date.now();
15452
+ if (hit && now - hit.at < TTL_MS) return hit.v;
15453
+ let v = false;
15454
+ try {
15455
+ v = realGit.isAncestor(repoDir, sha, baseRef);
15456
+ } catch {
15457
+ v = false;
15458
+ }
15459
+ mergeCache.set(key, { at: now, v });
15460
+ return v;
15461
+ }
15462
+ var merger = (repoDir) => (sha, base) => repoDir ? mergedInto(repoDir, sha, base) : false;
15463
+ var depRows = (ids) => prisma.spec.findMany({
15464
+ where: { id: { in: ids } },
15465
+ select: { id: true, stage: true, headSha: true }
15466
+ });
15467
+ async function blockersForSpec(spec, repoDir) {
15468
+ const ids = dependsOnOf(spec);
15469
+ if (ids.length === 0) return [];
15470
+ const rows = await depRows(ids);
15471
+ return blockersFor(spec, new Map(rows.map((r) => [r.id, r])), merger(repoDir));
15472
+ }
15473
+ async function decorateBlocked(specs) {
15474
+ const ids = [...new Set(specs.flatMap(dependsOnOf))];
15475
+ if (ids.length === 0) return specs.map((s) => ({ ...s, dependsOn: [], blockedBy: [] }));
15476
+ const rows = await depRows(ids);
15477
+ const deps = new Map(rows.map((r) => [r.id, r]));
15478
+ const repos = /* @__PURE__ */ new Map();
15479
+ const out3 = [];
15480
+ for (const s of specs) {
15481
+ const own = dependsOnOf(s);
15482
+ if (own.length === 0) {
15483
+ out3.push({ ...s, dependsOn: [], blockedBy: [] });
15484
+ continue;
15485
+ }
15486
+ if (!repos.has(s.projectId)) repos.set(s.projectId, await resolveRepoDir(s.projectId));
15487
+ out3.push({ ...s, dependsOn: own, blockedBy: blockersFor(s, deps, merger(repos.get(s.projectId))) });
15488
+ }
15489
+ return out3;
15490
+ }
15491
+ async function validateDependsOn(specId, projectId, raw) {
15492
+ const ids = dependsOnOf({ dependsOn: raw });
15493
+ if (ids.length === 0) return { ok: true, ids: [] };
15494
+ if (specId && ids.includes(specId))
15495
+ return { ok: false, error: "backlog tak bisa bergantung pada dirinya sendiri" };
15496
+ const rows = await prisma.spec.findMany({
15497
+ where: { id: { in: ids } },
15498
+ select: { id: true, projectId: true }
15499
+ });
15500
+ const found = new Map(rows.map((r) => [r.id, r.projectId]));
15501
+ const missing = ids.filter((i) => !found.has(i));
15502
+ if (missing.length) return { ok: false, error: `backlog tak ditemukan: ${missing.join(", ")}` };
15503
+ const foreign = ids.filter((i) => found.get(i) !== projectId);
15504
+ if (foreign.length)
15505
+ return { ok: false, error: `dependency harus di project yang sama: ${foreign.join(", ")}` };
15506
+ if (specId) {
15507
+ const all = await prisma.spec.findMany({
15508
+ where: { projectId },
15509
+ select: { id: true, dependsOn: true }
15510
+ });
15511
+ const edges = new Map(all.map((r) => [r.id, dependsOnOf(r)]));
15512
+ edges.set(specId, ids);
15513
+ if (reaches(edges, ids, specId))
15514
+ return { ok: false, error: "dependency membentuk siklus" };
15515
+ }
15516
+ return { ok: true, ids };
15517
+ }
15518
+
15363
15519
  // src/services/doc-export.ts
15364
15520
  import PDFDocument from "pdfkit";
15365
15521
 
@@ -17829,7 +17985,7 @@ async function liveSpecs(filter = {}) {
17829
17985
  orderBy: { id: "desc" }
17830
17986
  });
17831
17987
  const live = sessionPhasesBySpec();
17832
- if (live.size === 0) return specs;
17988
+ if (live.size === 0) return decorateBlocked(specs);
17833
17989
  const advanced = [];
17834
17990
  const doneNow = [];
17835
17991
  const out3 = specs.map((s) => {
@@ -17847,7 +18003,7 @@ async function liveSpecs(filter = {}) {
17847
18003
  if (res.count > 0) await notifySynced("spec", a.id);
17848
18004
  }));
17849
18005
  await Promise.all(doneNow.map((d) => recordCompletion(d.specId, d.title, d.projectId)));
17850
- return out3;
18006
+ return decorateBlocked(out3);
17851
18007
  }
17852
18008
 
17853
18009
  // src/routes/specs.ts
@@ -17887,6 +18043,8 @@ async function specs_default(app2) {
17887
18043
  const repoDir = await resolveRepoDir(b.project);
17888
18044
  if (b.branchFrom && await branchUnknown(repoDir, b.branchFrom))
17889
18045
  return reply.code(400).send({ error: `branch "${b.branchFrom}" tidak ada di repo project` });
18046
+ const dep = await validateDependsOn(null, b.project, b.dependsOn ?? []);
18047
+ if (!dep.ok) return reply.code(400).send({ error: dep.error });
17890
18048
  const isQa = b.source === "qa";
17891
18049
  const { priority, objective } = deriveSpecFields(b.source, b.payload, b.priority);
17892
18050
  const author = req.user?.email ?? "system";
@@ -17905,7 +18063,9 @@ async function specs_default(app2) {
17905
18063
  author: isQa ? `QA \xB7 ${author}` : b.source === "audit" ? `Audit \xB7 ${author}` : b.source === "goal" ? `Goal \xB7 ${author}` : author,
17906
18064
  objective,
17907
18065
  payload: b.payload,
17908
- branchFrom: b.branchFrom ?? null
18066
+ branchFrom: b.branchFrom ?? null,
18067
+ dependsOn: dep.ids
18068
+ // SPEC-447 · ADR-0093
17909
18069
  }
17910
18070
  });
17911
18071
  } catch (e) {
@@ -17969,7 +18129,7 @@ ${item.context}` : item.context;
17969
18129
  if (!parsed.success) return reply.code(400).send({ error: parsed.error.flatten() });
17970
18130
  const spec = await prisma.spec.findUnique({ where: { id } });
17971
18131
  if (!spec) return reply.code(404).send({ error: "not found" });
17972
- const { branchFrom, stage, confirmDelete, title, priority: newPriority, payload } = parsed.data;
18132
+ const { branchFrom, stage, confirmDelete, title, priority: newPriority, payload, dependsOn } = parsed.data;
17973
18133
  const editingContent = title !== void 0 || newPriority !== void 0 || payload !== void 0;
17974
18134
  if (editingContent && (spec.stage !== "brainstorming" || spec.baseSha !== null))
17975
18135
  return reply.code(409).send({ error: "backlog item sudah dimulai \u2014 tak bisa diedit" });
@@ -17977,6 +18137,12 @@ ${item.context}` : item.context;
17977
18137
  if (await branchUnknown(await resolveRepoDir(spec.projectId), branchFrom))
17978
18138
  return reply.code(400).send({ error: `branch "${branchFrom}" tidak ada di repo project` });
17979
18139
  }
18140
+ let depIds;
18141
+ if (dependsOn !== void 0) {
18142
+ const d = await validateDependsOn(spec.id, spec.projectId, dependsOn);
18143
+ if (!d.ok) return reply.code(400).send({ error: d.error });
18144
+ depIds = d.ids;
18145
+ }
17980
18146
  if (stage !== void 0) {
17981
18147
  if (STAGES.indexOf(stage) >= STAGES.indexOf(spec.stage))
17982
18148
  return reply.code(422).send({ error: "stage hanya boleh dikembalikan mundur" });
@@ -17988,6 +18154,7 @@ ${item.context}` : item.context;
17988
18154
  }
17989
18155
  const data = {};
17990
18156
  if (branchFrom !== void 0) data.branchFrom = branchFrom;
18157
+ if (depIds !== void 0) data.dependsOn = depIds;
17991
18158
  if (stage !== void 0) data.stage = stage;
17992
18159
  if (editingContent) {
17993
18160
  const effPayload = payload ?? spec.payload;
@@ -18020,8 +18187,21 @@ ${item.context}` : item.context;
18020
18187
  });
18021
18188
  app2.delete("/specs/:id", async (req, reply) => {
18022
18189
  const { id } = req.params;
18190
+ const gone = await prisma.spec.findUnique({ where: { id }, select: { projectId: true } });
18023
18191
  await prisma.spec.delete({ where: { id } }).catch(() => {
18024
18192
  });
18193
+ if (gone) {
18194
+ const rows = await prisma.spec.findMany({
18195
+ where: { projectId: gone.projectId },
18196
+ select: { id: true, dependsOn: true }
18197
+ });
18198
+ for (const r of rows) {
18199
+ const ids = dependsOnOf(r);
18200
+ if (!ids.includes(id)) continue;
18201
+ await prisma.spec.update({ where: { id: r.id }, data: { dependsOn: ids.filter((x) => x !== id) } });
18202
+ await notifySynced("spec", r.id);
18203
+ }
18204
+ }
18025
18205
  return reply.code(204).send();
18026
18206
  });
18027
18207
  app2.post("/specs/:id/integrate", async (req, reply) => {
@@ -19251,9 +19431,11 @@ init_pty();
19251
19431
  init_session_phases();
19252
19432
  init_pty();
19253
19433
  var LaunchError = class extends Error {
19254
- constructor(message, kind) {
19434
+ // SPEC-447 · `blockers` hanya terisi untuk kind "blocked"; route memetakannya ke body 409.
19435
+ constructor(message, kind, blockers = []) {
19255
19436
  super(message);
19256
19437
  this.kind = kind;
19438
+ this.blockers = blockers;
19257
19439
  }
19258
19440
  };
19259
19441
  function resumeState(repoDir, worktree, branchTo, headSha) {
@@ -19275,6 +19457,11 @@ async function startSpecSession(spec, opts) {
19275
19457
  const id = sessionIdForSpec(spec.id);
19276
19458
  const pane = getSession(id);
19277
19459
  if (pane && !pane.exited) return { id: pane.id, reused: true };
19460
+ if (!opts.force) {
19461
+ const blockers = await blockersForSpec(spec, repoDir);
19462
+ if (blockers.length)
19463
+ throw new LaunchError(`${spec.id} ${blockedNote(blockers)}`, "blocked", blockers);
19464
+ }
19278
19465
  if (pane) killSession(id);
19279
19466
  const setting = await getSetting();
19280
19467
  const agent = opts.agent ?? setting.agent;
@@ -19437,12 +19624,16 @@ async function terminal_default(app2) {
19437
19624
  // SPEC-332 · ADR-0073
19438
19625
  agent: parsed.data.agent,
19439
19626
  // SPEC-338 · ADR-0074
19440
- verifyScope: parsed.data.verifyScope
19627
+ verifyScope: parsed.data.verifyScope,
19441
19628
  // SPEC-376 · ADR-0080
19629
+ force: parsed.data.force
19630
+ // SPEC-447 · ADR-0093
19442
19631
  });
19443
19632
  return reply.code(201).send(r.resumed ? { id: r.id, resumed: true } : { id: r.id });
19444
19633
  } catch (e) {
19445
19634
  if (e instanceof LaunchError) {
19635
+ if (e.kind === "blocked")
19636
+ return reply.code(409).send({ error: e.message, blocked: true, blockers: e.blockers });
19446
19637
  return e.kind === "needs-bind" ? reply.code(400).send({ error: e.message, needsBind: true }) : reply.code(422).send({ error: e.message });
19447
19638
  }
19448
19639
  throw e;
@@ -20378,7 +20569,7 @@ import { readFileSync as readFileSync9 } from "node:fs";
20378
20569
  import { homedir as homedir6 } from "node:os";
20379
20570
  import { join as join9 } from "node:path";
20380
20571
  var USAGE_URL = "https://api.anthropic.com/api/oauth/usage";
20381
- var TTL_MS = 3e4;
20572
+ var TTL_MS2 = 3e4;
20382
20573
  var lastOk = null;
20383
20574
  var freshUntil = 0;
20384
20575
  var LABELS = {
@@ -20456,7 +20647,7 @@ async function fetchUsage() {
20456
20647
  async function getLimits() {
20457
20648
  if (lastOk && Date.now() < freshUntil) return lastOk;
20458
20649
  const dto = await fetchUsage();
20459
- if (dto.status === "ok") freshUntil = Date.now() + TTL_MS;
20650
+ if (dto.status === "ok") freshUntil = Date.now() + TTL_MS2;
20460
20651
  return dto;
20461
20652
  }
20462
20653
 
@@ -20464,7 +20655,7 @@ async function getLimits() {
20464
20655
  import { open as open2, readdir as readdir2, stat } from "node:fs/promises";
20465
20656
  import { homedir as homedir7 } from "node:os";
20466
20657
  import { join as join10 } from "node:path";
20467
- var TTL_MS2 = 3e4;
20658
+ var TTL_MS3 = 3e4;
20468
20659
  var STALE_AFTER_MS = 12 * 36e5;
20469
20660
  var TAIL_BYTES = 512 * 1024;
20470
20661
  var MAX_FILES = 8;
@@ -20569,7 +20760,7 @@ async function getCodexLimits(dir2) {
20569
20760
  const dto = await readLimits(codexSessionsDir());
20570
20761
  if (dto.status !== "unavailable") {
20571
20762
  cache2 = dto;
20572
- freshUntil2 = Date.now() + TTL_MS2;
20763
+ freshUntil2 = Date.now() + TTL_MS3;
20573
20764
  }
20574
20765
  return dto;
20575
20766
  }
@@ -20587,7 +20778,7 @@ import { execFile as execFile8 } from "node:child_process";
20587
20778
  import { promisify as promisify8 } from "node:util";
20588
20779
  var run = promisify8(execFile8);
20589
20780
  var CODEX_MIN_CLIENT = "0.144.0";
20590
- var TTL_MS3 = 5 * 6e4;
20781
+ var TTL_MS4 = 5 * 6e4;
20591
20782
  var cache3 = null;
20592
20783
  var codexBin2 = () => effectiveStr("HANOMAN_CODEX_BIN") ?? "codex";
20593
20784
  function parseCodexVersion(out3) {
@@ -20595,7 +20786,7 @@ function parseCodexVersion(out3) {
20595
20786
  return m ? `${m[1]}.${m[2]}.${m[3]}` : null;
20596
20787
  }
20597
20788
  async function getCodexVersion(now = Date.now()) {
20598
- if (cache3 && now - cache3.at < TTL_MS3) return cache3.version;
20789
+ if (cache3 && now - cache3.at < TTL_MS4) return cache3.version;
20599
20790
  let version = null;
20600
20791
  try {
20601
20792
  const { stdout } = await run(codexBin2(), ["--version"], { timeout: 5e3 });
@@ -21825,6 +22016,11 @@ async function markDone(id, note) {
21825
22016
  data: { status: "done", ...note ? { note } : {} }
21826
22017
  });
21827
22018
  }
22019
+ async function noteQueued(id, note) {
22020
+ const row = await prisma.schedulerQueueItem.findUnique({ where: { id }, select: { note: true } });
22021
+ if (row?.note === note) return;
22022
+ await prisma.schedulerQueueItem.update({ where: { id }, data: { note } });
22023
+ }
21828
22024
 
21829
22025
  // src/services/scheduler/registry.ts
21830
22026
  var sources = /* @__PURE__ */ new Map();
@@ -22113,6 +22309,7 @@ function toDecisionView(r) {
22113
22309
 
22114
22310
  // src/services/lead/brain.ts
22115
22311
  init_config2();
22312
+ init_pty();
22116
22313
  import { execFile as execFile9 } from "node:child_process";
22117
22314
  var binFor = (agent) => agent === "codex" ? effectiveStr("HANOMAN_CODEX_BIN") ?? "codex" : effectiveStr("HANOMAN_CLAUDE_BIN") ?? "claude";
22118
22315
  function leadArgv(o) {
@@ -22133,14 +22330,16 @@ function leadArgv(o) {
22133
22330
  o.prompt
22134
22331
  ];
22135
22332
  }
22333
+ var leadEnv = (agent, base = process.env, uid = process.getuid?.()) => agent === "claude" ? { ...rootBypassEnv(uid), ...base } : { ...base };
22136
22334
  function think(prompt, o) {
22137
22335
  const bin = binFor(o.agent);
22138
22336
  const args = leadArgv({ agent: o.agent, model: o.model, effort: o.effort, prompt });
22139
22337
  return new Promise((resolve14, reject) => {
22140
- execFile9(bin, args, {
22338
+ const child = execFile9(bin, args, {
22141
22339
  cwd: o.cwd,
22142
22340
  timeout: o.timeoutMs,
22143
22341
  maxBuffer: 16 * 1024 * 1024,
22342
+ env: leadEnv(o.agent),
22144
22343
  encoding: "utf8",
22145
22344
  killSignal: "SIGTERM"
22146
22345
  }, (err, stdout, stderr) => {
@@ -22151,6 +22350,7 @@ function think(prompt, o) {
22151
22350
  }
22152
22351
  resolve14(stdout);
22153
22352
  });
22353
+ child.stdin?.end();
22154
22354
  });
22155
22355
  }
22156
22356
 
@@ -22736,11 +22936,22 @@ async function orderReadyWork(optIn, deps) {
22736
22936
  async function orderProject(projectId, deps) {
22737
22937
  const project = await prisma.project.findUnique({ where: { id: projectId }, select: { schedulerOptIn: true } });
22738
22938
  if (!project?.schedulerOptIn) return 0;
22739
- const ready = await prisma.spec.findMany({
22939
+ const readyRaw = await prisma.spec.findMany({
22740
22940
  where: { ...UNSTARTED_SPEC_WHERE, projectId },
22741
- select: { id: true, projectId: true, title: true, priority: true, objective: true },
22941
+ select: {
22942
+ id: true,
22943
+ projectId: true,
22944
+ title: true,
22945
+ priority: true,
22946
+ objective: true,
22947
+ branchFrom: true,
22948
+ dependsOn: true
22949
+ },
22742
22950
  orderBy: { id: "asc" }
22743
22951
  });
22952
+ const repoDir = await resolveRepoDir(projectId);
22953
+ const ready = [];
22954
+ for (const r of readyRaw) if ((await blockersForSpec(r, repoDir)).length === 0) ready.push(r);
22744
22955
  if (ready.length < 2) return 0;
22745
22956
  const already = new Set((await prisma.schedulerQueueItem.findMany({
22746
22957
  where: { specId: { in: ready.map((r) => r.id) } },
@@ -23407,6 +23618,11 @@ async function drain(cfg, deps) {
23407
23618
  await markDone(item.id, ALREADY_DONE_NOTE);
23408
23619
  continue;
23409
23620
  }
23621
+ const blocked = await deps.blockers(item.specId);
23622
+ if (blocked.length) {
23623
+ await noteQueued(item.id, blockedNote(blocked));
23624
+ continue;
23625
+ }
23410
23626
  const liveId = deps.isLive(item.specId);
23411
23627
  if (liveId) {
23412
23628
  await markLaunched(item.id, liveId);
@@ -23536,6 +23752,13 @@ var prodDeps = {
23536
23752
  const s = await prisma.spec.findUnique({ where: { id: specId }, select: { stage: true } });
23537
23753
  return s?.stage === "done";
23538
23754
  },
23755
+ // SPEC-447 · ADR-0093 · dibaca ULANG dari DB tepat sebelum launch, seperti `isDone`: `dependsOn`
23756
+ // bisa ditulis operator selagi item mengantre, dan merged-ness bergerak sendiri saat ada integrate.
23757
+ blockers: async (specId) => {
23758
+ const spec = await prisma.spec.findUnique({ where: { id: specId } });
23759
+ if (!spec) return [];
23760
+ return blockersForSpec(spec, await resolveRepoDir(spec.projectId));
23761
+ },
23539
23762
  launch: async (item, autonomy) => {
23540
23763
  const spec = await prisma.spec.findUnique({ where: { id: item.specId } });
23541
23764
  if (!spec) throw new Error(`spec ${item.specId} tak ada`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hanoman",
3
- "version": "0.1.7",
3
+ "version": "0.1.8",
4
4
  "description": "Orchestrator + dashboard workflow docs-driven untuk sesi Claude Code / Codex",
5
5
  "type": "module",
6
6
  "repository": {
@@ -0,0 +1,7 @@
1
+ -- SPEC-447 · ADR-0093 · dependency antar-backlog.
2
+ --
3
+ -- ADITIF & nullable tanpa default → `ADD COLUMN` polos sudah cukup. Beda dari migration SPEC-408
4
+ -- yang harus meredefinisi tabel: yang dilarang SQLite adalah `ADD COLUMN … DEFAULT <non-konstan>`,
5
+ -- bukan `ADD COLUMN` itu sendiri. Baris lama tetap NULL, dan pembaca menerjemahkan NULL → []
6
+ -- (`dependsOnOf` di services/spec-deps.ts) — jadi tak ada backfill yang perlu dilakukan.
7
+ ALTER TABLE "Spec" ADD COLUMN "dependsOn" JSONB;
@@ -46,6 +46,12 @@ model Spec {
46
46
  // sama dengan `baseSha` (services/session-launch.ts); jalur RESUME (ADR-0084) sengaja tidak
47
47
  // menimpanya — melanjutkan sesi bukan "mulai lagi". null = belum pernah dikerjakan.
48
48
  startedAt DateTime?
49
+ // SPEC-447 · ADR-0093 · id spec lain yang harus SELESAI & commit-nya sudah ada di branch basis
50
+ // sebelum item ini boleh diluncurkan. Array JSON of string; null/[] = tak bergantung apa pun.
51
+ // Sengaja kolom, bukan tabel join: SQLite melarang scalar list, `Json` sudah dipakai `payload`,
52
+ // dan kolom ikut FIELDS.spec sync apa adanya. Integritas ditegakkan di boundary route +
53
+ // pembersihan saat spec dihapus, bukan FK.
54
+ dependsOn Json?
49
55
  updatedAt DateTime @updatedAt
50
56
  project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
51
57
  }