issy 0.10.1 → 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.
package/README.md CHANGED
@@ -187,6 +187,8 @@ Issues can declare blockers with `depends_on`:
187
187
  depends_on: 0012, 0035
188
188
  ```
189
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
+
190
192
  ### Hooks
191
193
 
192
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.
package/dist/cli.js CHANGED
@@ -404,6 +404,41 @@ function getBlockingIssues(issue, issues) {
404
404
  function isIssueUnblocked(issue, issues) {
405
405
  return issue.frontmatter.status === "open" && getBlockingIssues(issue, issues).length === 0;
406
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
+ }
407
442
  function getIssueIdFromFilename(filename) {
408
443
  const match = filename.match(/^(\d+)-/);
409
444
  return match ? match[1] : filename.replace(".md", "");
@@ -460,17 +495,7 @@ async function getAllIssues() {
460
495
  content: body
461
496
  });
462
497
  }
463
- return issues.sort((a, b) => {
464
- const orderA = a.frontmatter.order;
465
- const orderB = b.frontmatter.order;
466
- if (orderA && orderB)
467
- return orderA < orderB ? -1 : orderA > orderB ? 1 : 0;
468
- if (orderA && !orderB)
469
- return -1;
470
- if (!orderA && orderB)
471
- return 1;
472
- return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
473
- });
498
+ return issues.sort(compareIssuesByRoadmapOrder);
474
499
  }
475
500
  async function getOpenIssuesByOrder() {
476
501
  const allIssues = await getAllIssues();
@@ -558,8 +583,7 @@ async function createIssue(input) {
558
583
  const content = `${generateFrontmatter(frontmatter)}
559
584
  ${body}
560
585
  `;
561
- await writeFile(join(getIssuesDir(), filename), content);
562
- return {
586
+ const newIssue = {
563
587
  id: issueNumber,
564
588
  filename,
565
589
  frontmatter,
@@ -567,6 +591,9 @@ ${body}
567
591
  ${body}
568
592
  `
569
593
  };
594
+ validateRoadmapDependencyOrder([...existingIssues, newIssue]);
595
+ await writeFile(join(getIssuesDir(), filename), content);
596
+ return newIssue;
570
597
  }
571
598
  async function updateIssue(id, input) {
572
599
  const issue = await getIssue(id);
@@ -596,12 +623,16 @@ ${input.body}
596
623
  ` : issue.content;
597
624
  const content = `${generateFrontmatter(updatedFrontmatter)}
598
625
  ${updatedContent}`;
599
- await writeFile(join(getIssuesDir(), issue.filename), content);
600
- return {
626
+ const updatedIssue = {
601
627
  ...issue,
602
628
  frontmatter: updatedFrontmatter,
603
629
  content: updatedContent
604
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;
605
636
  }
606
637
  async function closeIssue(id) {
607
638
  return updateIssue(id, { status: "closed" });
@@ -2002,17 +2033,7 @@ function createSearchIndex(issues) {
2002
2033
  function sortIssues(issues, sortBy) {
2003
2034
  const sortOption = sortBy.toLowerCase();
2004
2035
  if (sortOption === "roadmap") {
2005
- issues.sort((a, b) => {
2006
- const orderA = a.frontmatter.order;
2007
- const orderB = b.frontmatter.order;
2008
- if (orderA && orderB)
2009
- return orderA < orderB ? -1 : orderA > orderB ? 1 : 0;
2010
- if (orderA && !orderB)
2011
- return -1;
2012
- if (!orderA && orderB)
2013
- return 1;
2014
- return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
2015
- });
2036
+ issues.sort(compareIssuesByRoadmapOrder);
2016
2037
  } else if (sortOption === "priority") {
2017
2038
  const priorityOrder = {
2018
2039
  high: 0,
@@ -2066,17 +2087,7 @@ function sortIssues(issues, sortBy) {
2066
2087
  } else if (sortOption === "id") {
2067
2088
  issues.sort((a, b) => b.id.localeCompare(a.id));
2068
2089
  } else {
2069
- issues.sort((a, b) => {
2070
- const orderA = a.frontmatter.order;
2071
- const orderB = b.frontmatter.order;
2072
- if (orderA && orderB)
2073
- return orderA < orderB ? -1 : orderA > orderB ? 1 : 0;
2074
- if (orderA && !orderB)
2075
- return -1;
2076
- if (!orderA && orderB)
2077
- return 1;
2078
- return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
2079
- });
2090
+ issues.sort(compareIssuesByRoadmapOrder);
2080
2091
  }
2081
2092
  }
2082
2093
  function filterByQuery(issues, query) {
package/dist/main.js CHANGED
@@ -415,6 +415,41 @@ function getBlockingIssues(issue, issues) {
415
415
  function isIssueUnblocked(issue, issues) {
416
416
  return issue.frontmatter.status === "open" && getBlockingIssues(issue, issues).length === 0;
417
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
+ }
418
453
  function getIssueIdFromFilename(filename) {
419
454
  const match = filename.match(/^(\d+)-/);
420
455
  return match ? match[1] : filename.replace(".md", "");
@@ -471,17 +506,7 @@ async function getAllIssues() {
471
506
  content: body
472
507
  });
473
508
  }
474
- return issues.sort((a, b) => {
475
- const orderA = a.frontmatter.order;
476
- const orderB = b.frontmatter.order;
477
- if (orderA && orderB)
478
- return orderA < orderB ? -1 : orderA > orderB ? 1 : 0;
479
- if (orderA && !orderB)
480
- return -1;
481
- if (!orderA && orderB)
482
- return 1;
483
- return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
484
- });
509
+ return issues.sort(compareIssuesByRoadmapOrder);
485
510
  }
486
511
  async function getOpenIssuesByOrder() {
487
512
  const allIssues = await getAllIssues();
@@ -569,8 +594,7 @@ async function createIssue(input) {
569
594
  const content = `${generateFrontmatter(frontmatter)}
570
595
  ${body}
571
596
  `;
572
- await writeFile(join(getIssuesDir(), filename), content);
573
- return {
597
+ const newIssue = {
574
598
  id: issueNumber,
575
599
  filename,
576
600
  frontmatter,
@@ -578,6 +602,9 @@ ${body}
578
602
  ${body}
579
603
  `
580
604
  };
605
+ validateRoadmapDependencyOrder([...existingIssues, newIssue]);
606
+ await writeFile(join(getIssuesDir(), filename), content);
607
+ return newIssue;
581
608
  }
582
609
  async function updateIssue(id, input) {
583
610
  const issue = await getIssue(id);
@@ -607,12 +634,16 @@ ${input.body}
607
634
  ` : issue.content;
608
635
  const content = `${generateFrontmatter(updatedFrontmatter)}
609
636
  ${updatedContent}`;
610
- await writeFile(join(getIssuesDir(), issue.filename), content);
611
- return {
637
+ const updatedIssue = {
612
638
  ...issue,
613
639
  frontmatter: updatedFrontmatter,
614
640
  content: updatedContent
615
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;
616
647
  }
617
648
  async function closeIssue(id) {
618
649
  return updateIssue(id, { status: "closed" });
@@ -2013,17 +2044,7 @@ function createSearchIndex(issues) {
2013
2044
  function sortIssues(issues, sortBy) {
2014
2045
  const sortOption = sortBy.toLowerCase();
2015
2046
  if (sortOption === "roadmap") {
2016
- issues.sort((a, b) => {
2017
- const orderA = a.frontmatter.order;
2018
- const orderB = b.frontmatter.order;
2019
- if (orderA && orderB)
2020
- return orderA < orderB ? -1 : orderA > orderB ? 1 : 0;
2021
- if (orderA && !orderB)
2022
- return -1;
2023
- if (!orderA && orderB)
2024
- return 1;
2025
- return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
2026
- });
2047
+ issues.sort(compareIssuesByRoadmapOrder);
2027
2048
  } else if (sortOption === "priority") {
2028
2049
  const priorityOrder = {
2029
2050
  high: 0,
@@ -2077,17 +2098,7 @@ function sortIssues(issues, sortBy) {
2077
2098
  } else if (sortOption === "id") {
2078
2099
  issues.sort((a, b) => b.id.localeCompare(a.id));
2079
2100
  } else {
2080
- issues.sort((a, b) => {
2081
- const orderA = a.frontmatter.order;
2082
- const orderB = b.frontmatter.order;
2083
- if (orderA && orderB)
2084
- return orderA < orderB ? -1 : orderA > orderB ? 1 : 0;
2085
- if (orderA && !orderB)
2086
- return -1;
2087
- if (!orderA && orderB)
2088
- return 1;
2089
- return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
2090
- });
2101
+ issues.sort(compareIssuesByRoadmapOrder);
2091
2102
  }
2092
2103
  }
2093
2104
  function filterByQuery(issues, query) {
@@ -2218,6 +2229,7 @@ var compact = markdown([
2218
2229
  "- When reopening an issue and other open issues exist, include exactly one position flag.",
2219
2230
  "- Use dependency order: prerequisites first, dependent/user-facing work later. Use `--last` when placement is unclear.",
2220
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.",
2221
2233
  "- `issy list` shows a `Blk` column with open blocker counts; `-` means unblocked.",
2222
2234
  "- `issy list --unblocked` shows only open issues with no open blockers.",
2223
2235
  "",
@@ -2310,6 +2322,7 @@ var topics = [
2310
2322
  "- Replace blockers with `issy update <id> --depends-on 0001,0003`.",
2311
2323
  '- Clear blockers with `issy update <id> --depends-on ""`.',
2312
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.",
2313
2326
  "- `issy list` shows a compact `Blk` column. `-` means unblocked; otherwise the value is the number of open blockers.",
2314
2327
  "- `issy list --unblocked` shows only open issues with no open blockers.",
2315
2328
  "",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "issy",
3
- "version": "0.10.1",
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.1",
39
- "@miketromba/issy-core": "^0.10.1",
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
  }