@markmdev/pebble 0.1.18 → 0.1.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli/index.js CHANGED
@@ -294,6 +294,8 @@ function computeState(events) {
294
294
  comments: [],
295
295
  createdAt: event.timestamp,
296
296
  updatedAt: event.timestamp,
297
+ statusChangedAt: event.timestamp,
298
+ // Initial status is 'open'
297
299
  lastSource: event.source
298
300
  };
299
301
  issues.set(event.issueId, issue);
@@ -313,6 +315,9 @@ function computeState(events) {
313
315
  issue.priority = updateEvent.data.priority;
314
316
  }
315
317
  if (updateEvent.data.status !== void 0) {
318
+ if (issue.status !== updateEvent.data.status) {
319
+ issue.statusChangedAt = event.timestamp;
320
+ }
316
321
  issue.status = updateEvent.data.status;
317
322
  }
318
323
  if (updateEvent.data.description !== void 0) {
@@ -336,6 +341,7 @@ function computeState(events) {
336
341
  const issue = issues.get(event.issueId);
337
342
  if (issue) {
338
343
  issue.status = "closed";
344
+ issue.statusChangedAt = event.timestamp;
339
345
  issue.updatedAt = event.timestamp;
340
346
  if (event.source) issue.lastSource = event.source;
341
347
  }
@@ -345,6 +351,7 @@ function computeState(events) {
345
351
  const issue = issues.get(event.issueId);
346
352
  if (issue) {
347
353
  issue.status = "open";
354
+ issue.statusChangedAt = event.timestamp;
348
355
  issue.updatedAt = event.timestamp;
349
356
  if (event.source) issue.lastSource = event.source;
350
357
  }
@@ -559,10 +566,12 @@ function getBlocking(issueId) {
559
566
  (issue) => issue.blockedBy.includes(issueId)
560
567
  );
561
568
  }
562
- function getChildren(epicId) {
569
+ function getChildren(epicId, includeDeleted = false) {
563
570
  const events = readEvents();
564
571
  const state = computeState(events);
565
- return Array.from(state.values()).filter((issue) => issue.parent === epicId);
572
+ return Array.from(state.values()).filter(
573
+ (issue) => issue.parent === epicId && (includeDeleted || !issue.deleted)
574
+ );
566
575
  }
567
576
  function getVerifications(issueId) {
568
577
  const events = readEvents();
@@ -707,7 +716,8 @@ function formatIssueDetailPretty(issue, ctx) {
707
716
  lines.push("\u2500".repeat(60));
708
717
  lines.push(`Type: ${formatType(issue.type)}`);
709
718
  lines.push(`Priority: ${formatPriority(issue.priority)}`);
710
- lines.push(`Status: ${formatStatus(issue.status)}`);
719
+ const statusTime = issue.statusChangedAt ? ` (${formatRelativeTime(issue.statusChangedAt)})` : "";
720
+ lines.push(`Status: ${formatStatus(issue.status)}${statusTime}`);
711
721
  if (ctx.ancestry && ctx.ancestry.length > 0) {
712
722
  const chain = [...ctx.ancestry].reverse().map((a) => a.id).join(" > ");
713
723
  lines.push(`Ancestry: ${chain}`);
@@ -764,8 +774,8 @@ function formatIssueDetailPretty(issue, ctx) {
764
774
  }
765
775
  }
766
776
  lines.push("");
767
- lines.push(`Created: ${new Date(issue.createdAt).toLocaleString()}`);
768
- lines.push(`Updated: ${new Date(issue.updatedAt).toLocaleString()}`);
777
+ lines.push(`Created: ${formatRelativeTime(issue.createdAt)}`);
778
+ lines.push(`Updated: ${formatRelativeTime(issue.updatedAt)}`);
769
779
  return lines.join("\n");
770
780
  }
771
781
  function outputIssueDetail(issue, ctx, pretty) {
@@ -859,11 +869,30 @@ function formatErrorPretty(error) {
859
869
  const message = error instanceof Error ? error.message : error;
860
870
  return `Error: ${message}`;
861
871
  }
862
- function outputMutationSuccess(id, pretty) {
872
+ function outputMutationSuccess(id, pretty, extra) {
863
873
  if (pretty) {
864
- console.log(`\u2713 ${id}`);
874
+ const notes = [];
875
+ if (extra?.parentReopened) {
876
+ notes.push(`parent ${extra.parentReopened.id} reopened`);
877
+ }
878
+ if (extra?.blockersReopened?.length) {
879
+ const ids = extra.blockersReopened.map((b) => b.id).join(", ");
880
+ notes.push(`blocker${extra.blockersReopened.length > 1 ? "s" : ""} ${ids} reopened`);
881
+ }
882
+ if (notes.length > 0) {
883
+ console.log(`\u2713 ${id} (${notes.join(", ")})`);
884
+ } else {
885
+ console.log(`\u2713 ${id}`);
886
+ }
865
887
  } else {
866
- console.log(JSON.stringify({ id, success: true }));
888
+ const result = { id, success: true };
889
+ if (extra?.parentReopened) {
890
+ result._parentReopened = extra.parentReopened;
891
+ }
892
+ if (extra?.blockersReopened?.length) {
893
+ result._blockersReopened = extra.blockersReopened;
894
+ }
895
+ console.log(JSON.stringify(result));
867
896
  }
868
897
  }
869
898
  function outputIssueList(issues, pretty, limitInfo) {
@@ -900,6 +929,7 @@ function buildIssueTree(issues) {
900
929
  priority: issue.priority,
901
930
  status: issue.status,
902
931
  createdAt: issue.createdAt,
932
+ statusChangedAt: issue.statusChangedAt,
903
933
  childrenCount: children.length,
904
934
  ...children.length > 0 && { children }
905
935
  };
@@ -925,7 +955,7 @@ function formatIssueTreePretty(nodes, sectionHeader) {
925
955
  const connector = isRoot ? "" : isLast ? "\u2514\u2500 " : "\u251C\u2500 ";
926
956
  const statusIcon = node.status === "closed" ? "\u2713" : node.status === "in_progress" ? "\u25B6" : node.status === "pending_verification" ? "\u23F3" : "\u25CB";
927
957
  const statusText = STATUS_LABELS[node.status].toLowerCase();
928
- const relativeTime = formatRelativeTime(node.createdAt);
958
+ const relativeTime = node.statusChangedAt ? formatRelativeTime(node.statusChangedAt) : formatRelativeTime(node.createdAt);
929
959
  lines.push(`${prefix}${connector}${statusIcon} ${node.id}: ${node.title} [${node.type}] P${node.priority} ${statusText} ${relativeTime}`);
930
960
  const children = node.children ?? [];
931
961
  const childPrefix = isRoot ? "" : prefix + (isLast ? " " : "\u2502 ");
@@ -975,8 +1005,9 @@ function formatIssueListVerbose(issues, sectionHeader) {
975
1005
  }
976
1006
  for (const info of issues) {
977
1007
  const { issue, blocking, children, verifications, blockers, ancestry } = info;
1008
+ const statusTime = issue.statusChangedAt ? formatRelativeTime(issue.statusChangedAt) : formatRelativeTime(issue.createdAt);
978
1009
  lines.push(`${issue.id}: ${issue.title}`);
979
- lines.push(` Type: ${formatType(issue.type)} | Priority: P${issue.priority} | Created: ${formatRelativeTime(issue.createdAt)}`);
1010
+ lines.push(` Type: ${formatType(issue.type)} | Priority: P${issue.priority} | Status: ${formatStatus(issue.status)} (${statusTime})`);
980
1011
  if (ancestry.length > 0) {
981
1012
  const chain = [...ancestry].reverse().map((a) => a.title).join(" \u2192 ");
982
1013
  lines.push(` Ancestry: ${chain}`);
@@ -1039,6 +1070,7 @@ function createCommand(program2) {
1039
1070
  const pebbleDir = getOrCreatePebbleDir();
1040
1071
  const config = getConfig(pebbleDir);
1041
1072
  let parentId;
1073
+ let parentReopened;
1042
1074
  if (options.parent) {
1043
1075
  parentId = resolveId(options.parent);
1044
1076
  const parent = getIssue(parentId);
@@ -1049,7 +1081,14 @@ function createCommand(program2) {
1049
1081
  throw new Error(`Verification issues cannot be parents`);
1050
1082
  }
1051
1083
  if (parent.status === "closed") {
1052
- throw new Error(`Cannot add children to closed issue: ${parentId}`);
1084
+ const reopenEvent = {
1085
+ type: "reopen",
1086
+ issueId: parentId,
1087
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1088
+ data: { reason: "Reopened to add child" }
1089
+ };
1090
+ appendEvent(reopenEvent, pebbleDir);
1091
+ parentReopened = { id: parentId, title: parent.title };
1053
1092
  }
1054
1093
  }
1055
1094
  let verifiesId;
@@ -1061,6 +1100,7 @@ function createCommand(program2) {
1061
1100
  }
1062
1101
  }
1063
1102
  const blockedByIds = [];
1103
+ const blockersReopened = [];
1064
1104
  if (options.blockedBy) {
1065
1105
  const ids = options.blockedBy.split(",").map((s) => s.trim()).filter(Boolean);
1066
1106
  for (const rawId of ids) {
@@ -1070,7 +1110,14 @@ function createCommand(program2) {
1070
1110
  throw new Error(`Blocker issue not found: ${rawId}`);
1071
1111
  }
1072
1112
  if (blocker.status === "closed") {
1073
- throw new Error(`Cannot be blocked by closed issue: ${resolvedId}`);
1113
+ const reopenEvent = {
1114
+ type: "reopen",
1115
+ issueId: resolvedId,
1116
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1117
+ data: { reason: "Reopened to block new issue" }
1118
+ };
1119
+ appendEvent(reopenEvent, pebbleDir);
1120
+ blockersReopened.push({ id: resolvedId, title: blocker.title });
1074
1121
  }
1075
1122
  blockedByIds.push(resolvedId);
1076
1123
  }
@@ -1103,6 +1150,15 @@ function createCommand(program2) {
1103
1150
  }
1104
1151
  };
1105
1152
  appendEvent(event, pebbleDir);
1153
+ if (parentId) {
1154
+ const parentUpdateEvent = {
1155
+ type: "update",
1156
+ issueId: parentId,
1157
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1158
+ data: {}
1159
+ };
1160
+ appendEvent(parentUpdateEvent, pebbleDir);
1161
+ }
1106
1162
  if (blockedByIds.length > 0) {
1107
1163
  const depEvent = {
1108
1164
  type: "update",
@@ -1123,7 +1179,8 @@ function createCommand(program2) {
1123
1179
  };
1124
1180
  appendEvent(depEvent, pebbleDir);
1125
1181
  }
1126
- outputMutationSuccess(id, pretty);
1182
+ const extra = parentReopened || blockersReopened.length > 0 ? { parentReopened, blockersReopened: blockersReopened.length > 0 ? blockersReopened : void 0 } : void 0;
1183
+ outputMutationSuccess(id, pretty, extra);
1127
1184
  } catch (error) {
1128
1185
  outputError(error, pretty);
1129
1186
  }
@@ -1166,6 +1223,7 @@ function updateCommand(program2) {
1166
1223
  data.description = options.description;
1167
1224
  hasChanges = true;
1168
1225
  }
1226
+ let parentReopened;
1169
1227
  if (options.parent !== void 0) {
1170
1228
  if (options.parent.toLowerCase() === "null") {
1171
1229
  data.parent = "";
@@ -1179,7 +1237,14 @@ function updateCommand(program2) {
1179
1237
  throw new Error(`Verification issues cannot be parents`);
1180
1238
  }
1181
1239
  if (parentIssue.status === "closed") {
1182
- throw new Error(`Cannot set parent to closed issue: ${parentId}`);
1240
+ const reopenEvent = {
1241
+ type: "reopen",
1242
+ issueId: parentId,
1243
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1244
+ data: { reason: "Reopened to add child" }
1245
+ };
1246
+ appendEvent(reopenEvent, pebbleDir);
1247
+ parentReopened = { id: parentId, title: parentIssue.title };
1183
1248
  }
1184
1249
  data.parent = parentId;
1185
1250
  }
@@ -1218,7 +1283,7 @@ function updateCommand(program2) {
1218
1283
  if (allIds.length === 1) {
1219
1284
  const result = results[0];
1220
1285
  if (result.success) {
1221
- outputMutationSuccess(result.id, pretty);
1286
+ outputMutationSuccess(result.id, pretty, parentReopened ? { parentReopened } : void 0);
1222
1287
  } else {
1223
1288
  throw new Error(result.error || "Unknown error");
1224
1289
  }
@@ -1268,8 +1333,8 @@ function closeCommand(program2) {
1268
1333
  results.push({ id: resolvedId, success: false, error: `Issue is already closed: ${resolvedId}` });
1269
1334
  continue;
1270
1335
  }
1271
- if (issue.type === "epic" && hasOpenChildren(resolvedId)) {
1272
- results.push({ id: resolvedId, success: false, error: `Cannot close epic with open children: ${resolvedId}` });
1336
+ if (hasOpenChildren(resolvedId)) {
1337
+ results.push({ id: resolvedId, success: false, error: `Cannot close issue with open children: ${resolvedId}` });
1273
1338
  continue;
1274
1339
  }
1275
1340
  const timestamp = (/* @__PURE__ */ new Date()).toISOString();
@@ -2714,6 +2779,7 @@ data: ${message}
2714
2779
  res.status(400).json({ error: "Priority must be 0-4" });
2715
2780
  return;
2716
2781
  }
2782
+ let parentReopened;
2717
2783
  if (parent) {
2718
2784
  const parentIssue = getIssue(parent);
2719
2785
  if (!parentIssue) {
@@ -2725,8 +2791,14 @@ data: ${message}
2725
2791
  return;
2726
2792
  }
2727
2793
  if (parentIssue.status === "closed") {
2728
- res.status(400).json({ error: "Cannot add children to a closed issue" });
2729
- return;
2794
+ const reopenEvent = {
2795
+ type: "reopen",
2796
+ issueId: parent,
2797
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
2798
+ data: { reason: "Reopened to add child" }
2799
+ };
2800
+ appendEvent(reopenEvent, pebbleDir);
2801
+ parentReopened = { id: parent, title: parentIssue.title };
2730
2802
  }
2731
2803
  }
2732
2804
  const issueId = generateId(config.prefix);
@@ -2744,8 +2816,18 @@ data: ${message}
2744
2816
  }
2745
2817
  };
2746
2818
  appendEvent(event, pebbleDir);
2819
+ if (parent) {
2820
+ const parentUpdateEvent = {
2821
+ type: "update",
2822
+ issueId: parent,
2823
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
2824
+ data: {}
2825
+ };
2826
+ appendEvent(parentUpdateEvent, pebbleDir);
2827
+ }
2747
2828
  const issue = getIssue(issueId);
2748
- res.status(201).json(issue);
2829
+ const result = parentReopened ? { ...issue, _parentReopened: parentReopened } : issue;
2830
+ res.status(201).json(result);
2749
2831
  } catch (error) {
2750
2832
  res.status(500).json({ error: error.message });
2751
2833
  }
@@ -2771,8 +2853,8 @@ data: ${message}
2771
2853
  results.push({ id: issueId, success: true });
2772
2854
  continue;
2773
2855
  }
2774
- if (issue.type === "epic" && hasOpenChildren(issueId)) {
2775
- results.push({ id: issueId, success: false, error: "Cannot close epic with open children" });
2856
+ if (hasOpenChildren(issueId)) {
2857
+ results.push({ id: issueId, success: false, error: "Cannot close issue with open children" });
2776
2858
  continue;
2777
2859
  }
2778
2860
  const pendingVerifications = getVerifications(issueId).filter((v) => v.status !== "closed");
@@ -3022,8 +3104,8 @@ data: ${message}
3022
3104
  res.status(400).json({ error: "Issue is already closed" });
3023
3105
  return;
3024
3106
  }
3025
- if (!isMultiWorktree() && issue.type === "epic" && hasOpenChildren(issueId)) {
3026
- res.status(400).json({ error: "Cannot close epic with open children" });
3107
+ if (!isMultiWorktree() && hasOpenChildren(issueId)) {
3108
+ res.status(400).json({ error: "Cannot close issue with open children" });
3027
3109
  return;
3028
3110
  }
3029
3111
  const { reason } = req.body;
@@ -3836,6 +3918,21 @@ function formatInProgressPretty(issues) {
3836
3918
  }
3837
3919
  return lines.join("\n");
3838
3920
  }
3921
+ function formatClosedIssuesPretty(issues) {
3922
+ if (issues.length === 0) return "";
3923
+ const lines = [];
3924
+ lines.push(`## Recently Closed (${issues.length})`);
3925
+ lines.push("");
3926
+ for (const issue of issues) {
3927
+ lines.push(`\u2713 ${issue.id}: ${issue.title} [${issue.type}]`);
3928
+ lines.push(` Closed: ${formatRelativeTime(issue.closedAt)}${issue.lastSource ? ` | Source: ${issue.lastSource}` : ""}`);
3929
+ if (issue.parent) {
3930
+ lines.push(` Parent: ${issue.parent.title}`);
3931
+ }
3932
+ lines.push("");
3933
+ }
3934
+ return lines.join("\n");
3935
+ }
3839
3936
  function formatSummaryPretty(summaries, sectionHeader) {
3840
3937
  if (summaries.length === 0) {
3841
3938
  return "No epics found.";
@@ -3950,6 +4047,27 @@ function summaryCommand(program2) {
3950
4047
  const limitedClosed = limit > 0 ? closedEpics.slice(0, limit) : closedEpics;
3951
4048
  const openSummaries = limitedOpen.map(buildSummary);
3952
4049
  const closedSummaries = limitedClosed.map(buildSummary);
4050
+ let closedIssues = getIssues({ status: "closed" });
4051
+ closedIssues.sort(
4052
+ (a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
4053
+ );
4054
+ closedIssues = closedIssues.slice(0, 20);
4055
+ const recentlyClosedSummaries = closedIssues.map((issue) => {
4056
+ const summary = {
4057
+ id: issue.id,
4058
+ title: issue.title,
4059
+ type: issue.type,
4060
+ closedAt: issue.updatedAt,
4061
+ lastSource: issue.lastSource
4062
+ };
4063
+ if (issue.parent) {
4064
+ const parentIssue = getIssue(issue.parent, true);
4065
+ if (parentIssue) {
4066
+ summary.parent = { id: parentIssue.id, title: parentIssue.title };
4067
+ }
4068
+ }
4069
+ return summary;
4070
+ });
3953
4071
  if (pretty) {
3954
4072
  const output = [];
3955
4073
  const inProgressOutput = formatInProgressPretty(inProgressSummaries);
@@ -3964,12 +4082,16 @@ function summaryCommand(program2) {
3964
4082
  if (output.length > 0) output.push("");
3965
4083
  output.push(formatSummaryPretty(closedSummaries, "Recently Closed Epics (last 72h)"));
3966
4084
  }
4085
+ if (recentlyClosedSummaries.length > 0) {
4086
+ if (output.length > 0) output.push("");
4087
+ output.push(formatClosedIssuesPretty(recentlyClosedSummaries));
4088
+ }
3967
4089
  if (output.length === 0) {
3968
4090
  output.push("No issues in progress and no epics found.");
3969
4091
  }
3970
4092
  console.log(output.join("\n"));
3971
4093
  } else {
3972
- console.log(formatJson({ inProgress: inProgressSummaries, open: openSummaries, closed: closedSummaries }));
4094
+ console.log(formatJson({ inProgress: inProgressSummaries, open: openSummaries, closed: closedSummaries, recentlyClosed: recentlyClosedSummaries }));
3973
4095
  }
3974
4096
  } catch (error) {
3975
4097
  outputError(error, pretty);