issy 0.10.0 → 0.11.0

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.
Files changed (4) hide show
  1. package/README.md +16 -0
  2. package/dist/cli.js +166 -47
  3. package/dist/main.js +131 -41
  4. package/package.json +3 -3
package/README.md CHANGED
@@ -90,6 +90,7 @@ Once installed globally, you can run commands from your terminal:
90
90
  ```bash
91
91
  issy # Start the web UI
92
92
  issy list # List open issues (roadmap order)
93
+ issy list --unblocked # List open issues with no open blockers
93
94
  issy next # Show next issue to work on
94
95
  issy create --title "Bug" # Create an issue
95
96
  issy learn # Print AI-agent instructions
@@ -137,12 +138,15 @@ Opens a local read-only UI at `http://localhost:1554` for browsing issues.
137
138
  issy init # Create .issy/issues/ directory
138
139
  issy init --seed # Create with a welcome issue
139
140
  issy list # List open issues (roadmap order)
141
+ issy list --unblocked # List open issues with no open blockers
140
142
  issy next # Show next issue to work on
141
143
  issy search "auth" # Fuzzy search
142
144
  issy read 0001 # View issue
143
145
  issy create --title "Bug" --after 0002 # Create issue after #0002
146
+ issy create --title "Feature" --depends-on 0012,0035 --last # Blocked by issues
144
147
  issy create --title "Bug" --body "Details here" --last # Create with body content
145
148
  issy update 0001 --before 0003 # Reposition in roadmap
149
+ issy update 0001 --depends-on 0012,0035 # Replace blockers
146
150
  issy update 0001 --body "New details" # Replace body content
147
151
  issy close 0001 # Close issue
148
152
  issy reopen 0001 --after 0004 # Reopen and place in roadmap
@@ -175,6 +179,16 @@ issy list --sort priority # Sort by priority instead
175
179
  issy list --sort created # Sort by creation date
176
180
  ```
177
181
 
182
+ The `Blk` column in `issy list` shows the count of currently open blockers. `-` means the issue is unblocked. Use `issy list --unblocked` to show only open issues that have no open blockers. Dependency IDs that do not match an existing issue are ignored.
183
+
184
+ Issues can declare blockers with `depends_on`:
185
+
186
+ ```yaml
187
+ depends_on: 0012, 0035
188
+ ```
189
+
190
+ Roadmap placement is validated against open dependencies. An open issue must appear after every open issue it depends on, and `create`, `update`, or `reopen` fails if the requested position would put a blocked issue before one of its blockers.
191
+
178
192
  ### Hooks
179
193
 
180
194
  issy supports optional hook files in `.issy/` that inject context into stdout after successful operations. The file contents are printed directly, making them visible to AI agents in their command output.
@@ -226,6 +240,7 @@ scope: medium
226
240
  type: bug
227
241
  status: open
228
242
  order: a0
243
+ depends_on: 0012, 0035
229
244
  created: 2025-01-15T10:30:00
230
245
  ---
231
246
 
@@ -243,6 +258,7 @@ session isn't established, causing a redirect loop.
243
258
  | `status` | `open`, `closed` |
244
259
  | `labels` | comma-separated (optional) |
245
260
  | `order` | fractional index key (managed by issy) |
261
+ | `depends_on` | comma-separated blocking issue IDs (optional) |
246
262
 
247
263
  ## Configuration
248
264
 
package/dist/cli.js CHANGED
@@ -355,6 +355,9 @@ function generateFrontmatter(data) {
355
355
  if (data.order) {
356
356
  lines.push(`order: ${data.order}`);
357
357
  }
358
+ if (data.depends_on) {
359
+ lines.push(`depends_on: ${data.depends_on}`);
360
+ }
358
361
  lines.push(`created: ${data.created}`);
359
362
  if (data.updated) {
360
363
  lines.push(`updated: ${data.updated}`);
@@ -363,6 +366,79 @@ function generateFrontmatter(data) {
363
366
  return lines.join(`
364
367
  `);
365
368
  }
369
+ function parseDependencyIds(dependsOn) {
370
+ if (!dependsOn)
371
+ return [];
372
+ const seen = new Set;
373
+ const ids = [];
374
+ for (const token of dependsOn.split(/[,\s]+/)) {
375
+ const value = token.trim().replace(/^['"]+|['"]+$/g, "").replace(/^#/, "");
376
+ if (!/^\d+$/.test(value))
377
+ continue;
378
+ const id = value.padStart(4, "0");
379
+ if (seen.has(id))
380
+ continue;
381
+ seen.add(id);
382
+ ids.push(id);
383
+ }
384
+ return ids;
385
+ }
386
+ function filterExistingDependencyIds(dependsOn, issues, excludeId) {
387
+ const existingIds = new Set(issues.map((issue) => issue.id));
388
+ const excludedId = excludeId?.padStart(4, "0");
389
+ return parseDependencyIds(dependsOn).filter((id) => existingIds.has(id) && id !== excludedId);
390
+ }
391
+ function formatExistingDependencyIds(dependsOn, issues, excludeId) {
392
+ return filterExistingDependencyIds(dependsOn, issues, excludeId).join(", ");
393
+ }
394
+ function getDependencyIssues(issue, issues) {
395
+ const dependencyIds = filterExistingDependencyIds(issue.frontmatter.depends_on, issues, issue.id);
396
+ if (dependencyIds.length === 0)
397
+ return [];
398
+ const issueById = new Map(issues.map((i) => [i.id, i]));
399
+ return dependencyIds.map((id) => issueById.get(id)).filter((dependency) => Boolean(dependency));
400
+ }
401
+ function getBlockingIssues(issue, issues) {
402
+ return getDependencyIssues(issue, issues).filter((dependency) => dependency.frontmatter.status === "open");
403
+ }
404
+ function isIssueUnblocked(issue, issues) {
405
+ return issue.frontmatter.status === "open" && getBlockingIssues(issue, issues).length === 0;
406
+ }
407
+ function compareIssuesByRoadmapOrder(a, b) {
408
+ const orderA = a.frontmatter.order;
409
+ const orderB = b.frontmatter.order;
410
+ if (orderA && orderB) {
411
+ if (orderA < orderB)
412
+ return -1;
413
+ if (orderA > orderB)
414
+ return 1;
415
+ }
416
+ if (orderA && !orderB)
417
+ return -1;
418
+ if (!orderA && orderB)
419
+ return 1;
420
+ return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
421
+ }
422
+ function validateRoadmapDependencyOrder(issues) {
423
+ const openIssues = issues.filter((issue) => issue.frontmatter.status === "open").sort(compareIssuesByRoadmapOrder);
424
+ const positionById = new Map(openIssues.map((issue, index) => [issue.id, index]));
425
+ const violations = [];
426
+ for (const issue of openIssues) {
427
+ const issuePosition = positionById.get(issue.id);
428
+ if (issuePosition === undefined)
429
+ continue;
430
+ for (const dependency of getBlockingIssues(issue, issues)) {
431
+ const dependencyPosition = positionById.get(dependency.id);
432
+ if (dependencyPosition === undefined || dependencyPosition < issuePosition) {
433
+ continue;
434
+ }
435
+ violations.push(`#${issue.id} depends on #${dependency.id}`);
436
+ }
437
+ }
438
+ if (violations.length > 0) {
439
+ throw new Error(`Roadmap dependency order invalid: ${violations.join("; ")}. Issues must be placed after all open issues they depend on.`);
440
+ }
441
+ }
366
442
  function getIssueIdFromFilename(filename) {
367
443
  const match = filename.match(/^(\d+)-/);
368
444
  return match ? match[1] : filename.replace(".md", "");
@@ -419,17 +495,7 @@ async function getAllIssues() {
419
495
  content: body
420
496
  });
421
497
  }
422
- return issues.sort((a, b) => {
423
- const orderA = a.frontmatter.order;
424
- const orderB = b.frontmatter.order;
425
- if (orderA && orderB)
426
- return orderA < orderB ? -1 : orderA > orderB ? 1 : 0;
427
- if (orderA && !orderB)
428
- return -1;
429
- if (!orderA && orderB)
430
- return 1;
431
- return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
432
- });
498
+ return issues.sort(compareIssuesByRoadmapOrder);
433
499
  }
434
500
  async function getOpenIssuesByOrder() {
435
501
  const allIssues = await getAllIssues();
@@ -496,6 +562,8 @@ async function createIssue(input) {
496
562
  const issueNumber = await getNextIssueNumber();
497
563
  const slug = createSlug(input.title);
498
564
  const filename = `${issueNumber}-${slug}.md`;
565
+ const existingIssues = await getAllIssues();
566
+ const dependsOn = formatExistingDependencyIds(input.depends_on, existingIssues, issueNumber);
499
567
  const frontmatter = {
500
568
  title: input.title,
501
569
  priority,
@@ -504,6 +572,7 @@ async function createIssue(input) {
504
572
  labels: input.labels || undefined,
505
573
  status: "open",
506
574
  order: input.order || undefined,
575
+ depends_on: dependsOn || undefined,
507
576
  created: formatDate()
508
577
  };
509
578
  const body = input.body ?? `
@@ -514,8 +583,7 @@ async function createIssue(input) {
514
583
  const content = `${generateFrontmatter(frontmatter)}
515
584
  ${body}
516
585
  `;
517
- await writeFile(join(getIssuesDir(), filename), content);
518
- return {
586
+ const newIssue = {
519
587
  id: issueNumber,
520
588
  filename,
521
589
  frontmatter,
@@ -523,12 +591,17 @@ ${body}
523
591
  ${body}
524
592
  `
525
593
  };
594
+ validateRoadmapDependencyOrder([...existingIssues, newIssue]);
595
+ await writeFile(join(getIssuesDir(), filename), content);
596
+ return newIssue;
526
597
  }
527
598
  async function updateIssue(id, input) {
528
599
  const issue = await getIssue(id);
529
600
  if (!issue) {
530
601
  throw new Error(`Issue not found: ${id}`);
531
602
  }
603
+ const allIssues = await getAllIssues();
604
+ const dependsOn = input.depends_on !== undefined ? formatExistingDependencyIds(input.depends_on, allIssues, issue.id) : undefined;
532
605
  const updatedFrontmatter = {
533
606
  ...issue.frontmatter,
534
607
  ...input.title && { title: input.title },
@@ -540,6 +613,9 @@ async function updateIssue(id, input) {
540
613
  },
541
614
  ...input.status && { status: input.status },
542
615
  ...input.order && { order: input.order },
616
+ ...input.depends_on !== undefined && {
617
+ depends_on: dependsOn || undefined
618
+ },
543
619
  updated: formatDate()
544
620
  };
545
621
  const updatedContent = input.body !== undefined ? `
@@ -547,12 +623,16 @@ ${input.body}
547
623
  ` : issue.content;
548
624
  const content = `${generateFrontmatter(updatedFrontmatter)}
549
625
  ${updatedContent}`;
550
- await writeFile(join(getIssuesDir(), issue.filename), content);
551
- return {
626
+ const updatedIssue = {
552
627
  ...issue,
553
628
  frontmatter: updatedFrontmatter,
554
629
  content: updatedContent
555
630
  };
631
+ if (input.order !== undefined || input.depends_on !== undefined || input.status === "open") {
632
+ validateRoadmapDependencyOrder(allIssues.map((current) => current.id === issue.id ? updatedIssue : current));
633
+ }
634
+ await writeFile(join(getIssuesDir(), issue.filename), content);
635
+ return updatedIssue;
556
636
  }
557
637
  async function closeIssue(id) {
558
638
  return updateIssue(id, { status: "closed" });
@@ -1953,17 +2033,7 @@ function createSearchIndex(issues) {
1953
2033
  function sortIssues(issues, sortBy) {
1954
2034
  const sortOption = sortBy.toLowerCase();
1955
2035
  if (sortOption === "roadmap") {
1956
- issues.sort((a, b) => {
1957
- const orderA = a.frontmatter.order;
1958
- const orderB = b.frontmatter.order;
1959
- if (orderA && orderB)
1960
- return orderA < orderB ? -1 : orderA > orderB ? 1 : 0;
1961
- if (orderA && !orderB)
1962
- return -1;
1963
- if (!orderA && orderB)
1964
- return 1;
1965
- return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
1966
- });
2036
+ issues.sort(compareIssuesByRoadmapOrder);
1967
2037
  } else if (sortOption === "priority") {
1968
2038
  const priorityOrder = {
1969
2039
  high: 0,
@@ -2017,17 +2087,7 @@ function sortIssues(issues, sortBy) {
2017
2087
  } else if (sortOption === "id") {
2018
2088
  issues.sort((a, b) => b.id.localeCompare(a.id));
2019
2089
  } else {
2020
- issues.sort((a, b) => {
2021
- const orderA = a.frontmatter.order;
2022
- const orderB = b.frontmatter.order;
2023
- if (orderA && orderB)
2024
- return orderA < orderB ? -1 : orderA > orderB ? 1 : 0;
2025
- if (orderA && !orderB)
2026
- return -1;
2027
- if (!orderA && orderB)
2028
- return 1;
2029
- return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
2030
- });
2090
+ issues.sort(compareIssuesByRoadmapOrder);
2031
2091
  }
2032
2092
  }
2033
2093
  function filterByQuery(issues, query) {
@@ -2039,6 +2099,14 @@ function filterByQuery(issues, query) {
2039
2099
  if (issue.frontmatter.status !== statusValue) {
2040
2100
  return false;
2041
2101
  }
2102
+ } else if (statusValue === "unblocked") {
2103
+ if (!isIssueUnblocked(issue, issues)) {
2104
+ return false;
2105
+ }
2106
+ } else if (statusValue === "blocked") {
2107
+ if (issue.frontmatter.status !== "open" || isIssueUnblocked(issue, issues)) {
2108
+ return false;
2109
+ }
2042
2110
  }
2043
2111
  }
2044
2112
  if (parsed.qualifiers.priority) {
@@ -2126,7 +2194,23 @@ function typeSymbol(type) {
2126
2194
  }
2127
2195
  function formatIssueRow(issue) {
2128
2196
  const status = issue.frontmatter.status === "open" ? "OPEN " : "CLOSED";
2129
- return ` ${issue.id} ${prioritySymbol(issue.frontmatter.priority)} ${typeSymbol(issue.frontmatter.type)} ${status} ${issue.frontmatter.title}`;
2197
+ const blocked = issue.blockers > 0 ? String(issue.blockers) : "-";
2198
+ return ` ${issue.id} ${prioritySymbol(issue.frontmatter.priority)} ${typeSymbol(issue.frontmatter.type)} ${status} ${blocked.padEnd(3)} ${issue.frontmatter.title}`;
2199
+ }
2200
+ function printDependsOn(issue, issues) {
2201
+ const dependencyIssues = getDependencyIssues(issue, issues);
2202
+ if (dependencyIssues.length === 0)
2203
+ return;
2204
+ console.log(` Depends on: ${dependencyIssues.map((i) => `#${i.id}`).join(", ")}`);
2205
+ }
2206
+ function getDependsOnArg(values) {
2207
+ const value = values["depends-on"];
2208
+ return typeof value === "string" ? value : undefined;
2209
+ }
2210
+ async function resolveDependsOn(value, excludeId) {
2211
+ if (value === undefined)
2212
+ return;
2213
+ return formatExistingDependencyIds(value, await getAllIssues(), excludeId);
2130
2214
  }
2131
2215
  async function resolvePosition(opts) {
2132
2216
  const openIssues = await getOpenIssuesByOrder();
@@ -2158,7 +2242,7 @@ function hasHelpFlag(commandArgs) {
2158
2242
  async function listIssues(options) {
2159
2243
  const allIssues = await getAllIssues();
2160
2244
  const queryParts = [];
2161
- if (!options.all)
2245
+ if (!options.all && !options.unblocked)
2162
2246
  queryParts.push("is:open");
2163
2247
  if (options.priority)
2164
2248
  queryParts.push(`priority:${options.priority}`);
@@ -2170,17 +2254,23 @@ async function listIssues(options) {
2170
2254
  queryParts.push(`sort:${options.sort}`);
2171
2255
  if (options.search)
2172
2256
  queryParts.push(options.search);
2173
- const query = queryParts.join(" ") || "is:open";
2174
- const issues = filterByQuery(allIssues, query);
2257
+ const query = queryParts.join(" ");
2258
+ let issues = query ? filterByQuery(allIssues, query) : allIssues;
2259
+ if (options.unblocked) {
2260
+ issues = issues.filter((issue) => isIssueUnblocked(issue, allIssues));
2261
+ }
2175
2262
  if (issues.length === 0) {
2176
2263
  console.log("No issues found.");
2177
2264
  return;
2178
2265
  }
2179
2266
  console.log(`
2180
- ID Pri Type Status Title`);
2267
+ ID Pri Type Status Blk Title`);
2181
2268
  console.log(` ${"-".repeat(100)}`);
2182
2269
  for (const issue of issues) {
2183
- console.log(formatIssueRow(issue));
2270
+ console.log(formatIssueRow({
2271
+ ...issue,
2272
+ blockers: getBlockingIssues(issue, allIssues).length
2273
+ }));
2184
2274
  }
2185
2275
  console.log(`
2186
2276
  Total: ${issues.length} issue(s)
@@ -2188,6 +2278,7 @@ async function listIssues(options) {
2188
2278
  }
2189
2279
  async function readIssue(id) {
2190
2280
  const issue = await getIssue(id);
2281
+ const allIssues = await getAllIssues();
2191
2282
  if (!issue) {
2192
2283
  console.error(`Issue not found: ${id}`);
2193
2284
  process.exit(1);
@@ -2206,6 +2297,7 @@ ${"=".repeat(70)}`);
2206
2297
  if (issue.frontmatter.labels) {
2207
2298
  console.log(` Labels: ${issue.frontmatter.labels}`);
2208
2299
  }
2300
+ printDependsOn(issue, allIssues);
2209
2301
  if (issue.frontmatter.order) {
2210
2302
  console.log(` Order: ${issue.frontmatter.order}`);
2211
2303
  }
@@ -2228,10 +2320,13 @@ async function searchIssuesCommand(query, options) {
2228
2320
  console.log(`
2229
2321
  Search results for "${query}":`);
2230
2322
  console.log(`
2231
- ID Pri Type Status Title`);
2323
+ ID Pri Type Status Blk Title`);
2232
2324
  console.log(` ${"-".repeat(100)}`);
2233
2325
  for (const issue of issues) {
2234
- console.log(formatIssueRow(issue));
2326
+ console.log(formatIssueRow({
2327
+ ...issue,
2328
+ blockers: getBlockingIssues(issue, allIssues).length
2329
+ }));
2235
2330
  }
2236
2331
  console.log(`
2237
2332
  Found: ${issues.length} issue(s)
@@ -2257,6 +2352,7 @@ async function createIssueCommand(options) {
2257
2352
  scope: options.scope,
2258
2353
  type: options.type,
2259
2354
  labels: options.labels,
2355
+ depends_on: await resolveDependsOn(options.dependsOn) || undefined,
2260
2356
  order
2261
2357
  };
2262
2358
  const issue = await createIssue(input);
@@ -2293,6 +2389,9 @@ async function updateIssueCommand(id, options) {
2293
2389
  scope: options.scope,
2294
2390
  type: options.type,
2295
2391
  labels: options.labels,
2392
+ ...options.dependsOn !== undefined && {
2393
+ depends_on: await resolveDependsOn(options.dependsOn, id) || ""
2394
+ },
2296
2395
  order
2297
2396
  });
2298
2397
  console.log(`Updated issue: ${issue.filename}`);
@@ -2341,6 +2440,7 @@ async function reopenIssueCommand(id, options) {
2341
2440
  }
2342
2441
  async function nextIssueCommand() {
2343
2442
  const issue = await getNextIssue();
2443
+ const allIssues = await getAllIssues();
2344
2444
  if (!issue) {
2345
2445
  console.log("No open issues.");
2346
2446
  return;
@@ -2359,6 +2459,7 @@ ${"=".repeat(70)}`);
2359
2459
  if (issue.frontmatter.labels) {
2360
2460
  console.log(` Labels: ${issue.frontmatter.labels}`);
2361
2461
  }
2462
+ printDependsOn(issue, allIssues);
2362
2463
  if (issue.frontmatter.order) {
2363
2464
  console.log(` Order: ${issue.frontmatter.order}`);
2364
2465
  }
@@ -2386,6 +2487,7 @@ Options:
2386
2487
  Commands:
2387
2488
  list List all open issues (roadmap order)
2388
2489
  --all, -a Include closed issues
2490
+ --unblocked Only show open issues with no open blockers
2389
2491
  --priority, -p <p> Filter by priority (high, medium, low)
2390
2492
  --scope <s> Filter by scope (small, medium, large)
2391
2493
  --type, -t <t> Filter by type (bug, improvement)
@@ -2410,6 +2512,7 @@ Commands:
2410
2512
  --scope <s> Scope (small, medium, large)
2411
2513
  --type <t> Type (bug, improvement)
2412
2514
  --labels, -l <l> Comma-separated labels
2515
+ --depends-on <ids> Comma-separated blocking issue IDs
2413
2516
  --before <id> Insert before this issue in roadmap
2414
2517
  --after <id> Insert after this issue in roadmap
2415
2518
  --first Insert at the beginning of the roadmap
@@ -2422,6 +2525,7 @@ Commands:
2422
2525
  --scope <s> New scope
2423
2526
  --type <t> New type
2424
2527
  --labels, -l <l> New labels
2528
+ --depends-on <ids> Replace blocking issue IDs (empty clears)
2425
2529
  --before <id> Move before this issue in roadmap
2426
2530
  --after <id> Move after this issue in roadmap
2427
2531
  --first Move to the beginning of the roadmap
@@ -2439,6 +2543,7 @@ Commands:
2439
2543
 
2440
2544
  Examples:
2441
2545
  issy list
2546
+ issy list --unblocked
2442
2547
  issy list --priority high --type bug
2443
2548
  issy next
2444
2549
  issy learn
@@ -2462,6 +2567,7 @@ List all open issues in roadmap order.
2462
2567
 
2463
2568
  Options:
2464
2569
  --all, -a Include closed issues
2570
+ --unblocked Only show open issues with no open blockers
2465
2571
  --priority, -p <p> Filter by priority (high, medium, low)
2466
2572
  --scope <s> Filter by scope (small, medium, large)
2467
2573
  --type, -t <t> Filter by type (bug, improvement)
@@ -2474,6 +2580,7 @@ Options:
2474
2580
  args: args.slice(1),
2475
2581
  options: {
2476
2582
  all: { type: "boolean", short: "a" },
2583
+ unblocked: { type: "boolean" },
2477
2584
  priority: { type: "string", short: "p" },
2478
2585
  scope: { type: "string" },
2479
2586
  type: { type: "string", short: "t" },
@@ -2551,6 +2658,7 @@ Options:
2551
2658
  --scope <s> Scope: small, medium, large
2552
2659
  --type <t> Type: bug, improvement (default: improvement)
2553
2660
  --labels, -l <l> Comma-separated labels
2661
+ --depends-on <ids> Comma-separated blocking issue IDs
2554
2662
  --before <id> Insert before this issue in roadmap
2555
2663
  --after <id> Insert after this issue in roadmap
2556
2664
  --first Insert at the beginning of the roadmap
@@ -2558,6 +2666,7 @@ Options:
2558
2666
 
2559
2667
  Examples:
2560
2668
  issy create --title "Fix login bug" --type bug --priority high --after 0002
2669
+ issy create --title "Add export" --depends-on 0001,0002 --last
2561
2670
  issy create --title "Add dark mode" --last
2562
2671
  `);
2563
2672
  return;
@@ -2571,6 +2680,7 @@ Examples:
2571
2680
  scope: { type: "string" },
2572
2681
  type: { type: "string" },
2573
2682
  labels: { type: "string", short: "l" },
2683
+ "depends-on": { type: "string" },
2574
2684
  before: { type: "string" },
2575
2685
  after: { type: "string" },
2576
2686
  first: { type: "boolean" },
@@ -2578,7 +2688,10 @@ Examples:
2578
2688
  },
2579
2689
  allowPositionals: true
2580
2690
  });
2581
- await createIssueCommand(values);
2691
+ await createIssueCommand({
2692
+ ...values,
2693
+ dependsOn: getDependsOnArg(values)
2694
+ });
2582
2695
  break;
2583
2696
  }
2584
2697
  case "update": {
@@ -2594,6 +2707,7 @@ Options:
2594
2707
  --scope <s> New scope: small, medium, large
2595
2708
  --type <t> New type: bug, improvement
2596
2709
  --labels, -l <l> New labels (comma-separated)
2710
+ --depends-on <ids> Replace blocking issue IDs (empty clears)
2597
2711
  --before <id> Move before this issue in roadmap
2598
2712
  --after <id> Move after this issue in roadmap
2599
2713
  --first Move to the beginning of the roadmap
@@ -2601,6 +2715,7 @@ Options:
2601
2715
 
2602
2716
  Examples:
2603
2717
  issy update 0001 --priority low --after 0003
2718
+ issy update 0002 --depends-on 0001,0003
2604
2719
  issy update 0002 --title "Renamed issue" --first
2605
2720
  `);
2606
2721
  return;
@@ -2619,6 +2734,7 @@ Examples:
2619
2734
  scope: { type: "string" },
2620
2735
  type: { type: "string" },
2621
2736
  labels: { type: "string", short: "l" },
2737
+ "depends-on": { type: "string" },
2622
2738
  before: { type: "string" },
2623
2739
  after: { type: "string" },
2624
2740
  first: { type: "boolean" },
@@ -2626,7 +2742,10 @@ Examples:
2626
2742
  },
2627
2743
  allowPositionals: true
2628
2744
  });
2629
- await updateIssueCommand(id, values);
2745
+ await updateIssueCommand(id, {
2746
+ ...values,
2747
+ dependsOn: getDependsOnArg(values)
2748
+ });
2630
2749
  break;
2631
2750
  }
2632
2751
  case "close": {
package/dist/main.js CHANGED
@@ -366,6 +366,9 @@ function generateFrontmatter(data) {
366
366
  if (data.order) {
367
367
  lines.push(`order: ${data.order}`);
368
368
  }
369
+ if (data.depends_on) {
370
+ lines.push(`depends_on: ${data.depends_on}`);
371
+ }
369
372
  lines.push(`created: ${data.created}`);
370
373
  if (data.updated) {
371
374
  lines.push(`updated: ${data.updated}`);
@@ -374,6 +377,79 @@ function generateFrontmatter(data) {
374
377
  return lines.join(`
375
378
  `);
376
379
  }
380
+ function parseDependencyIds(dependsOn) {
381
+ if (!dependsOn)
382
+ return [];
383
+ const seen = new Set;
384
+ const ids = [];
385
+ for (const token of dependsOn.split(/[,\s]+/)) {
386
+ const value = token.trim().replace(/^['"]+|['"]+$/g, "").replace(/^#/, "");
387
+ if (!/^\d+$/.test(value))
388
+ continue;
389
+ const id = value.padStart(4, "0");
390
+ if (seen.has(id))
391
+ continue;
392
+ seen.add(id);
393
+ ids.push(id);
394
+ }
395
+ return ids;
396
+ }
397
+ function filterExistingDependencyIds(dependsOn, issues, excludeId) {
398
+ const existingIds = new Set(issues.map((issue) => issue.id));
399
+ const excludedId = excludeId?.padStart(4, "0");
400
+ return parseDependencyIds(dependsOn).filter((id) => existingIds.has(id) && id !== excludedId);
401
+ }
402
+ function formatExistingDependencyIds(dependsOn, issues, excludeId) {
403
+ return filterExistingDependencyIds(dependsOn, issues, excludeId).join(", ");
404
+ }
405
+ function getDependencyIssues(issue, issues) {
406
+ const dependencyIds = filterExistingDependencyIds(issue.frontmatter.depends_on, issues, issue.id);
407
+ if (dependencyIds.length === 0)
408
+ return [];
409
+ const issueById = new Map(issues.map((i) => [i.id, i]));
410
+ return dependencyIds.map((id) => issueById.get(id)).filter((dependency) => Boolean(dependency));
411
+ }
412
+ function getBlockingIssues(issue, issues) {
413
+ return getDependencyIssues(issue, issues).filter((dependency) => dependency.frontmatter.status === "open");
414
+ }
415
+ function isIssueUnblocked(issue, issues) {
416
+ return issue.frontmatter.status === "open" && getBlockingIssues(issue, issues).length === 0;
417
+ }
418
+ function compareIssuesByRoadmapOrder(a, b) {
419
+ const orderA = a.frontmatter.order;
420
+ const orderB = b.frontmatter.order;
421
+ if (orderA && orderB) {
422
+ if (orderA < orderB)
423
+ return -1;
424
+ if (orderA > orderB)
425
+ return 1;
426
+ }
427
+ if (orderA && !orderB)
428
+ return -1;
429
+ if (!orderA && orderB)
430
+ return 1;
431
+ return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
432
+ }
433
+ function validateRoadmapDependencyOrder(issues) {
434
+ const openIssues = issues.filter((issue) => issue.frontmatter.status === "open").sort(compareIssuesByRoadmapOrder);
435
+ const positionById = new Map(openIssues.map((issue, index) => [issue.id, index]));
436
+ const violations = [];
437
+ for (const issue of openIssues) {
438
+ const issuePosition = positionById.get(issue.id);
439
+ if (issuePosition === undefined)
440
+ continue;
441
+ for (const dependency of getBlockingIssues(issue, issues)) {
442
+ const dependencyPosition = positionById.get(dependency.id);
443
+ if (dependencyPosition === undefined || dependencyPosition < issuePosition) {
444
+ continue;
445
+ }
446
+ violations.push(`#${issue.id} depends on #${dependency.id}`);
447
+ }
448
+ }
449
+ if (violations.length > 0) {
450
+ throw new Error(`Roadmap dependency order invalid: ${violations.join("; ")}. Issues must be placed after all open issues they depend on.`);
451
+ }
452
+ }
377
453
  function getIssueIdFromFilename(filename) {
378
454
  const match = filename.match(/^(\d+)-/);
379
455
  return match ? match[1] : filename.replace(".md", "");
@@ -430,17 +506,7 @@ async function getAllIssues() {
430
506
  content: body
431
507
  });
432
508
  }
433
- return issues.sort((a, b) => {
434
- const orderA = a.frontmatter.order;
435
- const orderB = b.frontmatter.order;
436
- if (orderA && orderB)
437
- return orderA < orderB ? -1 : orderA > orderB ? 1 : 0;
438
- if (orderA && !orderB)
439
- return -1;
440
- if (!orderA && orderB)
441
- return 1;
442
- return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
443
- });
509
+ return issues.sort(compareIssuesByRoadmapOrder);
444
510
  }
445
511
  async function getOpenIssuesByOrder() {
446
512
  const allIssues = await getAllIssues();
@@ -507,6 +573,8 @@ async function createIssue(input) {
507
573
  const issueNumber = await getNextIssueNumber();
508
574
  const slug = createSlug(input.title);
509
575
  const filename = `${issueNumber}-${slug}.md`;
576
+ const existingIssues = await getAllIssues();
577
+ const dependsOn = formatExistingDependencyIds(input.depends_on, existingIssues, issueNumber);
510
578
  const frontmatter = {
511
579
  title: input.title,
512
580
  priority,
@@ -515,6 +583,7 @@ async function createIssue(input) {
515
583
  labels: input.labels || undefined,
516
584
  status: "open",
517
585
  order: input.order || undefined,
586
+ depends_on: dependsOn || undefined,
518
587
  created: formatDate()
519
588
  };
520
589
  const body = input.body ?? `
@@ -525,8 +594,7 @@ async function createIssue(input) {
525
594
  const content = `${generateFrontmatter(frontmatter)}
526
595
  ${body}
527
596
  `;
528
- await writeFile(join(getIssuesDir(), filename), content);
529
- return {
597
+ const newIssue = {
530
598
  id: issueNumber,
531
599
  filename,
532
600
  frontmatter,
@@ -534,12 +602,17 @@ ${body}
534
602
  ${body}
535
603
  `
536
604
  };
605
+ validateRoadmapDependencyOrder([...existingIssues, newIssue]);
606
+ await writeFile(join(getIssuesDir(), filename), content);
607
+ return newIssue;
537
608
  }
538
609
  async function updateIssue(id, input) {
539
610
  const issue = await getIssue(id);
540
611
  if (!issue) {
541
612
  throw new Error(`Issue not found: ${id}`);
542
613
  }
614
+ const allIssues = await getAllIssues();
615
+ const dependsOn = input.depends_on !== undefined ? formatExistingDependencyIds(input.depends_on, allIssues, issue.id) : undefined;
543
616
  const updatedFrontmatter = {
544
617
  ...issue.frontmatter,
545
618
  ...input.title && { title: input.title },
@@ -551,6 +624,9 @@ async function updateIssue(id, input) {
551
624
  },
552
625
  ...input.status && { status: input.status },
553
626
  ...input.order && { order: input.order },
627
+ ...input.depends_on !== undefined && {
628
+ depends_on: dependsOn || undefined
629
+ },
554
630
  updated: formatDate()
555
631
  };
556
632
  const updatedContent = input.body !== undefined ? `
@@ -558,12 +634,16 @@ ${input.body}
558
634
  ` : issue.content;
559
635
  const content = `${generateFrontmatter(updatedFrontmatter)}
560
636
  ${updatedContent}`;
561
- await writeFile(join(getIssuesDir(), issue.filename), content);
562
- return {
637
+ const updatedIssue = {
563
638
  ...issue,
564
639
  frontmatter: updatedFrontmatter,
565
640
  content: updatedContent
566
641
  };
642
+ if (input.order !== undefined || input.depends_on !== undefined || input.status === "open") {
643
+ validateRoadmapDependencyOrder(allIssues.map((current) => current.id === issue.id ? updatedIssue : current));
644
+ }
645
+ await writeFile(join(getIssuesDir(), issue.filename), content);
646
+ return updatedIssue;
567
647
  }
568
648
  async function closeIssue(id) {
569
649
  return updateIssue(id, { status: "closed" });
@@ -1964,17 +2044,7 @@ function createSearchIndex(issues) {
1964
2044
  function sortIssues(issues, sortBy) {
1965
2045
  const sortOption = sortBy.toLowerCase();
1966
2046
  if (sortOption === "roadmap") {
1967
- issues.sort((a, b) => {
1968
- const orderA = a.frontmatter.order;
1969
- const orderB = b.frontmatter.order;
1970
- if (orderA && orderB)
1971
- return orderA < orderB ? -1 : orderA > orderB ? 1 : 0;
1972
- if (orderA && !orderB)
1973
- return -1;
1974
- if (!orderA && orderB)
1975
- return 1;
1976
- return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
1977
- });
2047
+ issues.sort(compareIssuesByRoadmapOrder);
1978
2048
  } else if (sortOption === "priority") {
1979
2049
  const priorityOrder = {
1980
2050
  high: 0,
@@ -2028,17 +2098,7 @@ function sortIssues(issues, sortBy) {
2028
2098
  } else if (sortOption === "id") {
2029
2099
  issues.sort((a, b) => b.id.localeCompare(a.id));
2030
2100
  } else {
2031
- issues.sort((a, b) => {
2032
- const orderA = a.frontmatter.order;
2033
- const orderB = b.frontmatter.order;
2034
- if (orderA && orderB)
2035
- return orderA < orderB ? -1 : orderA > orderB ? 1 : 0;
2036
- if (orderA && !orderB)
2037
- return -1;
2038
- if (!orderA && orderB)
2039
- return 1;
2040
- return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
2041
- });
2101
+ issues.sort(compareIssuesByRoadmapOrder);
2042
2102
  }
2043
2103
  }
2044
2104
  function filterByQuery(issues, query) {
@@ -2050,6 +2110,14 @@ function filterByQuery(issues, query) {
2050
2110
  if (issue.frontmatter.status !== statusValue) {
2051
2111
  return false;
2052
2112
  }
2113
+ } else if (statusValue === "unblocked") {
2114
+ if (!isIssueUnblocked(issue, issues)) {
2115
+ return false;
2116
+ }
2117
+ } else if (statusValue === "blocked") {
2118
+ if (issue.frontmatter.status !== "open" || isIssueUnblocked(issue, issues)) {
2119
+ return false;
2120
+ }
2053
2121
  }
2054
2122
  }
2055
2123
  if (parsed.qualifiers.priority) {
@@ -2137,12 +2205,13 @@ var compact = markdown([
2137
2205
  "",
2138
2206
  "## Essential workflow",
2139
2207
  "",
2140
- "- List work: `issy list` or `issy list --all`.",
2208
+ "- List work: `issy list`, `issy list --all`, or `issy list --unblocked`.",
2141
2209
  '- Search work: `issy search "query"` or `issy search "query" --all`.',
2142
2210
  "- Read before changing: `issy read <id>`.",
2143
2211
  "- Pick next work: `issy next`.",
2144
2212
  '- Create work: `issy create --title "..." --type bug|improvement --priority high|medium|low <position>`.',
2145
- "- Update work: `issy update <id> [options]`.",
2213
+ '- Create blocked work: `issy create --title "..." --depends-on 0001,0002 <position>`.',
2214
+ "- Update work: `issy update <id> [options]`, including `--depends-on <ids>` to replace blockers.",
2146
2215
  "- Close work: `issy close <id>` after the work is complete and verified.",
2147
2216
  "- Reopen work: `issy reopen <id> <position>`.",
2148
2217
  "",
@@ -2159,6 +2228,10 @@ var compact = markdown([
2159
2228
  "- When creating an issue and open issues already exist, include exactly one position flag: `--before <id>`, `--after <id>`, `--first`, or `--last`.",
2160
2229
  "- When reopening an issue and other open issues exist, include exactly one position flag.",
2161
2230
  "- Use dependency order: prerequisites first, dependent/user-facing work later. Use `--last` when placement is unclear.",
2231
+ "- Use `depends_on` / `--depends-on` when an issue cannot start until specific blocking issues are closed.",
2232
+ "- Roadmap placement is enforced against open dependencies: an open issue cannot be placed before an open issue it depends on.",
2233
+ "- `issy list` shows a `Blk` column with open blocker counts; `-` means unblocked.",
2234
+ "- `issy list --unblocked` shows only open issues with no open blockers.",
2162
2235
  "",
2163
2236
  "## Closing",
2164
2237
  "",
@@ -2242,6 +2315,17 @@ var topics = [
2242
2315
  "- Updating an issue: position flags are optional and reposition the issue when provided.",
2243
2316
  "- Never provide more than one position flag.",
2244
2317
  "",
2318
+ "## Explicit blockers",
2319
+ "",
2320
+ "- Use `depends_on` when an issue cannot start until specific blocking issues are closed.",
2321
+ '- Create blocked work with `issy create --title "..." --depends-on 0001,0002 --last`.',
2322
+ "- Replace blockers with `issy update <id> --depends-on 0001,0003`.",
2323
+ '- Clear blockers with `issy update <id> --depends-on ""`.',
2324
+ "- Missing or malformed dependency IDs are ignored.",
2325
+ "- Roadmap placement is enforced against open dependencies: an open issue cannot be placed before an open issue it depends on, and a blocker cannot be moved after open issues that depend on it.",
2326
+ "- `issy list` shows a compact `Blk` column. `-` means unblocked; otherwise the value is the number of open blockers.",
2327
+ "- `issy list --unblocked` shows only open issues with no open blockers.",
2328
+ "",
2245
2329
  "## Choosing placement",
2246
2330
  "",
2247
2331
  "- Place prerequisites before dependent issues.",
@@ -2253,9 +2337,12 @@ var topics = [
2253
2337
  "## Useful commands",
2254
2338
  "",
2255
2339
  "- `issy list` shows open issues in roadmap order.",
2340
+ "- `issy list --unblocked` shows open issues with no open blockers.",
2256
2341
  "- `issy next` shows the first open issue in roadmap order.",
2257
2342
  '- `issy create --title "..." --last` appends a new issue.',
2343
+ '- `issy create --title "..." --depends-on 0001,0002 --last` creates an issue blocked by other issues.',
2258
2344
  "- `issy update <id> --before <other-id>` repositions an issue.",
2345
+ "- `issy update <id> --depends-on 0001,0003` replaces blockers.",
2259
2346
  "- `issy reopen <id> --after <other-id>` reopens and places a closed issue."
2260
2347
  ])
2261
2348
  },
@@ -2272,6 +2359,7 @@ var topics = [
2272
2359
  "",
2273
2360
  "- `issy list`: list open issues in roadmap order.",
2274
2361
  "- `issy list --all`: include closed issues.",
2362
+ "- `issy list --unblocked`: list open issues with no open blockers.",
2275
2363
  "- `issy list --priority high|medium|low`: filter by priority.",
2276
2364
  "- `issy list --scope small|medium|large`: filter by scope.",
2277
2365
  "- `issy list --type bug|improvement`: filter by type.",
@@ -2289,10 +2377,11 @@ var topics = [
2289
2377
  "",
2290
2378
  '- `issy create --title "Fix login bug" --type bug --priority high --after 0002`.',
2291
2379
  '- `issy create --title "Add dark mode" --type improvement --last --labels "ui, frontend"`.',
2380
+ '- `issy create --title "Add export" --depends-on 0001,0002 --last`.',
2292
2381
  '- `issy create --title "Urgent fix" --first`.',
2293
2382
  '- `issy create --title "Fix crash" --body "## Problem\\n\\nApp crashes on startup." --last`.',
2294
2383
  "",
2295
- "Create options: `--title`, `--body`, `--priority`, `--scope`, `--type`, `--labels`, `--before`, `--after`, `--first`, `--last`.",
2384
+ "Create options: `--title`, `--body`, `--priority`, `--scope`, `--type`, `--labels`, `--depends-on`, `--before`, `--after`, `--first`, `--last`.",
2296
2385
  "",
2297
2386
  "## Update",
2298
2387
  "",
@@ -2300,9 +2389,10 @@ var topics = [
2300
2389
  "- `issy update <id> --after 0003`.",
2301
2390
  "- `issy update <id> --first`.",
2302
2391
  '- `issy update <id> --labels "api, backend"`.',
2392
+ "- `issy update <id> --depends-on 0001,0003`.",
2303
2393
  '- `issy update <id> --body "## Problem\\n\\nUpdated description."`.',
2304
2394
  "",
2305
- "Update options: `--title`, `--body`, `--priority`, `--scope`, `--type`, `--labels`, `--before`, `--after`, `--first`, `--last`.",
2395
+ "Update options: `--title`, `--body`, `--priority`, `--scope`, `--type`, `--labels`, `--depends-on`, `--before`, `--after`, `--first`, `--last`.",
2306
2396
  "",
2307
2397
  "## Close and reopen",
2308
2398
  "",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "issy",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "AI-native issue tracking. Markdown files in .issues/, managed by your coding assistant.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -35,8 +35,8 @@
35
35
  "lint": "biome check src bin"
36
36
  },
37
37
  "dependencies": {
38
- "@miketromba/issy-app": "^0.10.0",
39
- "@miketromba/issy-core": "^0.10.0",
38
+ "@miketromba/issy-app": "^0.11.0",
39
+ "@miketromba/issy-core": "^0.11.0",
40
40
  "update-notifier": "^7.3.1"
41
41
  }
42
42
  }