dsh-plugin-bmad-sprint-board 2.0.0 → 2.0.1
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 +15 -13
- package/client.js +208 -79
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -60,25 +60,27 @@ The board is a **check on** the ticket store, not a mirror of it.
|
|
|
60
60
|
|
|
61
61
|
| Detector | Fires when |
|
|
62
62
|
|---|---|
|
|
63
|
-
| **L1**
|
|
64
|
-
| **L2**
|
|
63
|
+
| **L1** unreadable ticket files | a file in a ticket folder is neither a leaf ticket nor a plan, so the store ignores it |
|
|
64
|
+
| **L2** unlisted leaves | a leaf ticket exists on disk with no entry in the ticket index |
|
|
65
65
|
| **L3** plans no ticket claims | a plan file corresponds to no index entry |
|
|
66
66
|
| **L4** unrecognised status | a ticket carries a status word this board does not know |
|
|
67
67
|
| **C1** epic settled with open tickets | an epic says `done` while tickets inside it are not |
|
|
68
68
|
| **C2** initiative settled with open tickets | an initiative says `done` while tickets inside it are not |
|
|
69
69
|
| **E1** initiative declares no epics | the folder exists and its store lists no epics |
|
|
70
70
|
|
|
71
|
-
**L1 is the one that matters on a migrated project.**
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
71
|
+
**L1 is the one that matters on a migrated project.** The ticket store decides
|
|
72
|
+
what a file is from its frontmatter `type`: `story`, `spike` or `bug` is a leaf
|
|
73
|
+
that carries the ticket, anything else declaring a `ticket` key is a plan over
|
|
74
|
+
one, and anything else is ignored. A file named `story-x-plan.md` whose type is
|
|
75
|
+
`feature` and which names no ticket is therefore *neither* — invisible to the
|
|
76
|
+
tooling, which reports its ticket as backlog however finished the work is. Every
|
|
77
|
+
number it prints is wrong while every file on disk looks correct.
|
|
78
|
+
|
|
79
|
+
**The board's lanes are the store's lanes.** States come from `tickets.py`'s own
|
|
80
|
+
table rather than being invented: an empty status is **backlog**, because a planned
|
|
81
|
+
story looks exactly like that before `bmad-build` writes its plan — a plan is an
|
|
82
|
+
artefact of building, not a precondition of existing. Only a status word the store
|
|
83
|
+
does not define becomes *unknown*, and it is shown but never counted as settled.
|
|
82
84
|
|
|
83
85
|
### How it reads a v7 project
|
|
84
86
|
|
package/client.js
CHANGED
|
@@ -516,6 +516,17 @@ window.__ModuleLoader__.load({
|
|
|
516
516
|
/** The prefix every plan file shares. */
|
|
517
517
|
const PLAN_SUFFIX = '-plan.md'
|
|
518
518
|
|
|
519
|
+
/**
|
|
520
|
+
* The types the ticket store treats as a ticket in its own right.
|
|
521
|
+
*
|
|
522
|
+
* `tickets.py` decides what a file is by its frontmatter `type`, not by its
|
|
523
|
+
* name: one of these types is a leaf that carries the ticket, anything else
|
|
524
|
+
* that declares a `ticket` key is a plan over one, and anything else is ignored.
|
|
525
|
+
* A file named `story-x-plan.md` whose type is `feature` is therefore neither,
|
|
526
|
+
* which is exactly how a migrated project quietly loses its plans.
|
|
527
|
+
*/
|
|
528
|
+
const LEAF_TYPES = new Set(['story', 'spike', 'bug'])
|
|
529
|
+
|
|
519
530
|
/**
|
|
520
531
|
* Reduce a title to the slug BMAD derives file names from.
|
|
521
532
|
*
|
|
@@ -562,6 +573,39 @@ window.__ModuleLoader__.load({
|
|
|
562
573
|
}))
|
|
563
574
|
}
|
|
564
575
|
|
|
576
|
+
/**
|
|
577
|
+
* Read a ticket file -- the leaf that carries the ticket itself.
|
|
578
|
+
*
|
|
579
|
+
* @param {string} text - the file's contents.
|
|
580
|
+
* @param {string} path - its workspace-relative path.
|
|
581
|
+
* @returns {{path: string, id: string|null, type: string|null, title: string|null, status: string}} the leaf.
|
|
582
|
+
*/
|
|
583
|
+
function readLeaf(text, path) {
|
|
584
|
+
const { data } = readFrontmatter(text)
|
|
585
|
+
return {
|
|
586
|
+
path,
|
|
587
|
+
id: stringOf(data, 'id'),
|
|
588
|
+
type: stringOf(data, 'type'),
|
|
589
|
+
title: stringOf(data, 'title'),
|
|
590
|
+
// an absent status is the empty string, which the store maps to backlog
|
|
591
|
+
status: stringOf(data, 'status') ?? '',
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* Decide what a file in a ticket folder is.
|
|
597
|
+
*
|
|
598
|
+
* @param {string} text - the file's contents.
|
|
599
|
+
* @returns {'leaf'|'plan'|'other'} the role the ticket store would give it.
|
|
600
|
+
*/
|
|
601
|
+
function classify(text) {
|
|
602
|
+
const { data } = readFrontmatter(text)
|
|
603
|
+
const type = stringOf(data, 'type')
|
|
604
|
+
if (type !== null && LEAF_TYPES.has(type)) return 'leaf'
|
|
605
|
+
if (stringOf(data, 'ticket') !== null) return 'plan'
|
|
606
|
+
return 'other'
|
|
607
|
+
}
|
|
608
|
+
|
|
565
609
|
/**
|
|
566
610
|
* Read a plan file's frontmatter into the fields the board uses.
|
|
567
611
|
*
|
|
@@ -575,8 +619,9 @@ window.__ModuleLoader__.load({
|
|
|
575
619
|
path,
|
|
576
620
|
title: stringOf(data, 'title'),
|
|
577
621
|
type: stringOf(data, 'type'),
|
|
578
|
-
status: stringOf(data, 'status'),
|
|
579
622
|
ticket: stringOf(data, 'ticket'),
|
|
623
|
+
status: stringOf(data, 'status') ?? '',
|
|
624
|
+
blockedReason: stringOf(data, 'blocked_reason'),
|
|
580
625
|
baseline: stringOf(data, 'baseline_commit'),
|
|
581
626
|
context: listOf(data, 'context'),
|
|
582
627
|
}
|
|
@@ -673,28 +718,53 @@ window.__ModuleLoader__.load({
|
|
|
673
718
|
|
|
674
719
|
|
|
675
720
|
/**
|
|
676
|
-
* The
|
|
721
|
+
* The store's own status-to-state table.
|
|
677
722
|
*
|
|
678
|
-
*
|
|
679
|
-
*
|
|
680
|
-
*
|
|
681
|
-
*
|
|
723
|
+
* Copied from `tickets.py` rather than invented, because the board's lanes have
|
|
724
|
+
* to be the lanes `tickets.py status` reports or the two disagree about the same
|
|
725
|
+
* repository. An empty status is **backlog**: a ticket exists in the index and
|
|
726
|
+
* nothing has happened to it, which is exactly how a planned story looks before
|
|
727
|
+
* `bmad-build` writes its plan. Reading that as "unknown" was this board getting
|
|
728
|
+
* it wrong -- a plan is an artefact of building, not a precondition of existing.
|
|
682
729
|
*/
|
|
683
|
-
const
|
|
684
|
-
'
|
|
685
|
-
'
|
|
686
|
-
'
|
|
687
|
-
'
|
|
688
|
-
'
|
|
689
|
-
'
|
|
690
|
-
'
|
|
691
|
-
|
|
730
|
+
const STATE_OF = {
|
|
731
|
+
'': 'backlog',
|
|
732
|
+
draft: 'backlog',
|
|
733
|
+
'ready-for-dev': 'backlog',
|
|
734
|
+
'in-progress': 'in-progress',
|
|
735
|
+
blocked: 'in-progress',
|
|
736
|
+
'in-review': 'review',
|
|
737
|
+
built: 'review',
|
|
738
|
+
done: 'done',
|
|
739
|
+
dropped: 'dropped',
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
/** The lane order, earliest first. */
|
|
743
|
+
const STATE_ORDER = ['backlog', 'in-progress', 'review', 'done', 'dropped']
|
|
692
744
|
|
|
693
|
-
/** States that mean the work is finished
|
|
745
|
+
/** States that mean the work is finished. */
|
|
694
746
|
const SETTLED = new Set(['done', 'dropped'])
|
|
695
747
|
|
|
696
|
-
/**
|
|
697
|
-
const KNOWN_STATUS = new Set(
|
|
748
|
+
/** Every status word the store understands. */
|
|
749
|
+
const KNOWN_STATUS = new Set(Object.keys(STATE_OF))
|
|
750
|
+
|
|
751
|
+
/** The lanes, plus the one for a word the store does not understand. */
|
|
752
|
+
const STATUS_ORDER = [...STATE_ORDER, 'unknown']
|
|
753
|
+
|
|
754
|
+
/**
|
|
755
|
+
* Map a status word to the lane it belongs in.
|
|
756
|
+
*
|
|
757
|
+
* A word the store does not know becomes its own lane and a finding, rather than
|
|
758
|
+
* being guessed at: `tickets.py` treats an unrecognised status as blocked, and
|
|
759
|
+
* silently inheriting that would hide a broken ticket behind a normal one.
|
|
760
|
+
*
|
|
761
|
+
* @param {string} status - the word from the ticket.
|
|
762
|
+
* @returns {string} the lane.
|
|
763
|
+
*/
|
|
764
|
+
function stateOf(status) {
|
|
765
|
+
const word = String(status ?? '')
|
|
766
|
+
return Object.hasOwn(STATE_OF, word) ? STATE_OF[word] : 'unknown'
|
|
767
|
+
}
|
|
698
768
|
|
|
699
769
|
/**
|
|
700
770
|
* Turn a BMAD slug into something a reader can scan.
|
|
@@ -751,16 +821,17 @@ window.__ModuleLoader__.load({
|
|
|
751
821
|
/**
|
|
752
822
|
* Decide a ticket's displayed state.
|
|
753
823
|
*
|
|
754
|
-
* A
|
|
755
|
-
*
|
|
756
|
-
*
|
|
824
|
+
* A plan overrides its leaf, and a leaf overrides the index row. With neither,
|
|
825
|
+
* the ticket is backlog -- not unknown. The only way to be unknown is to carry a
|
|
826
|
+
* status word the store does not define.
|
|
757
827
|
*
|
|
758
|
-
* @param {object|null} plan -
|
|
759
|
-
* @
|
|
828
|
+
* @param {object|null} plan - the joined plan, or null.
|
|
829
|
+
* @param {object|null} leaf - the leaf file, or null.
|
|
830
|
+
* @returns {string} the lane.
|
|
760
831
|
*/
|
|
761
|
-
function ticketStatus(plan) {
|
|
762
|
-
|
|
763
|
-
return
|
|
832
|
+
function ticketStatus(plan, leaf) {
|
|
833
|
+
const word = plan?.status ?? leaf?.status ?? ''
|
|
834
|
+
return stateOf(word)
|
|
764
835
|
}
|
|
765
836
|
|
|
766
837
|
/**
|
|
@@ -779,33 +850,84 @@ window.__ModuleLoader__.load({
|
|
|
779
850
|
const built = epics.map((epic) => {
|
|
780
851
|
const header = readEpicTicket(epic.ticket)
|
|
781
852
|
const entries = readStoryIndex(epic.storyIndex)
|
|
782
|
-
const plans = (epic.plans ?? []).map((plan) => readPlan(plan.text, plan.path))
|
|
783
|
-
const { links, unlinked, orphans, missing } = linkPlans(entries, plans)
|
|
784
853
|
|
|
785
|
-
|
|
786
|
-
|
|
854
|
+
// The store decides what a file is by its frontmatter type, not its name:
|
|
855
|
+
// a leaf carries a ticket, a file with a `ticket` key is a plan over one,
|
|
856
|
+
// and anything else is ignored. Splitting on the file name instead is what
|
|
857
|
+
// made a folder of `*-plan.md` files invisible.
|
|
858
|
+
const leaves = []
|
|
859
|
+
const plans = []
|
|
860
|
+
const ignored = []
|
|
861
|
+
for (const file of epic.files ?? []) {
|
|
862
|
+
const role = classify(file.text)
|
|
863
|
+
if (role === 'leaf') leaves.push(readLeaf(file.text, file.path))
|
|
864
|
+
else if (role === 'plan') plans.push(readPlan(file.text, file.path))
|
|
865
|
+
else ignored.push(file.path)
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
const usedLeaves = new Set()
|
|
869
|
+
const usedPlans = new Set()
|
|
870
|
+
|
|
871
|
+
const build = (entry) => {
|
|
872
|
+
const leaf = leaves.find((l) => l.id !== null && l.id === entry.id
|
|
873
|
+
|| l.path === `${entry.type}-${slugify(entry.title)}.md`) ?? null
|
|
874
|
+
if (leaf !== null) usedLeaves.add(leaf.path)
|
|
875
|
+
const plan = plans.find((pl) => pl.ticket === entry.id) ?? null
|
|
876
|
+
if (plan !== null) usedPlans.add(plan.path)
|
|
877
|
+
const word = plan?.status ?? leaf?.status ?? ''
|
|
787
878
|
return {
|
|
788
879
|
id: entry.id,
|
|
789
880
|
ref: `${epic.id ?? '?'}.${entry.id}`,
|
|
790
881
|
type: entry.type,
|
|
791
|
-
title: entry.title,
|
|
882
|
+
title: leaf?.title ?? entry.title,
|
|
792
883
|
v6Key: entry.v6Key,
|
|
793
|
-
|
|
884
|
+
word,
|
|
885
|
+
status: ticketStatus(plan, leaf),
|
|
794
886
|
plan,
|
|
795
|
-
|
|
887
|
+
leaf,
|
|
888
|
+
linkedBy: plan === null ? 'none' : 'ticket',
|
|
796
889
|
}
|
|
797
|
-
}
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
const tickets = entries.map(build)
|
|
893
|
+
|
|
894
|
+
// A leaf the index does not list is still a ticket the store knows about.
|
|
895
|
+
for (const leaf of leaves) {
|
|
896
|
+
if (usedLeaves.has(leaf.path) || leaf.id === null) continue
|
|
897
|
+
if (entries.some((entry) => entry.id === leaf.id)) continue
|
|
898
|
+
const plan = plans.find((pl) => pl.ticket === leaf.id) ?? null
|
|
899
|
+
if (plan !== null) usedPlans.add(plan.path)
|
|
900
|
+
tickets.push({
|
|
901
|
+
id: leaf.id,
|
|
902
|
+
ref: `${epic.id ?? '?'}.${leaf.id}`,
|
|
903
|
+
type: leaf.type ?? 'story',
|
|
904
|
+
title: leaf.title ?? leaf.path,
|
|
905
|
+
v6Key: null,
|
|
906
|
+
word: plan?.status ?? leaf.status,
|
|
907
|
+
status: ticketStatus(plan, leaf),
|
|
908
|
+
plan,
|
|
909
|
+
leaf,
|
|
910
|
+
linkedBy: plan === null ? 'none' : 'ticket',
|
|
911
|
+
unlisted: true,
|
|
912
|
+
})
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
const orphans = plans.filter((pl) => !usedPlans.has(pl.path))
|
|
798
916
|
|
|
799
917
|
return {
|
|
800
918
|
id: epic.id,
|
|
801
919
|
slug: epic.slug,
|
|
802
920
|
folder: epic.folder,
|
|
803
921
|
title: header.title ?? epic.title ?? epic.slug,
|
|
804
|
-
status
|
|
922
|
+
// A container's status is absent until its first ticket starts, which is a
|
|
923
|
+
// statement about the work rather than a gap in the file: it has not
|
|
924
|
+
// started. Rendering that as "unknown" says the opposite.
|
|
925
|
+
status: stateOf(header.status ?? ''),
|
|
926
|
+
rawStatus: header.status,
|
|
805
927
|
tickets,
|
|
806
928
|
orphans,
|
|
807
|
-
|
|
808
|
-
|
|
929
|
+
ignored,
|
|
930
|
+
leaves: leaves.length,
|
|
809
931
|
}
|
|
810
932
|
})
|
|
811
933
|
|
|
@@ -815,10 +937,7 @@ window.__ModuleLoader__.load({
|
|
|
815
937
|
for (const epic of built) {
|
|
816
938
|
for (const ticket of epic.tickets) {
|
|
817
939
|
total += 1
|
|
818
|
-
if (ticket.status ===
|
|
819
|
-
unknown += 1
|
|
820
|
-
continue
|
|
821
|
-
}
|
|
940
|
+
if (ticket.status === 'unknown') unknown += 1
|
|
822
941
|
byStatus.set(ticket.status, (byStatus.get(ticket.status) ?? 0) + 1)
|
|
823
942
|
}
|
|
824
943
|
}
|
|
@@ -827,7 +946,8 @@ window.__ModuleLoader__.load({
|
|
|
827
946
|
slug,
|
|
828
947
|
dir,
|
|
829
948
|
title: own.title ?? humanise(slug),
|
|
830
|
-
status: own.status,
|
|
949
|
+
status: stateOf(own.status ?? ''),
|
|
950
|
+
rawStatus: own.status,
|
|
831
951
|
epics: built,
|
|
832
952
|
counts: {
|
|
833
953
|
epics: built.length,
|
|
@@ -835,8 +955,8 @@ window.__ModuleLoader__.load({
|
|
|
835
955
|
unknown,
|
|
836
956
|
byStatus,
|
|
837
957
|
settled: [...byStatus].filter(([status]) => SETTLED.has(status)).reduce((n, [, c]) => n + c, 0),
|
|
838
|
-
unlinkedPlans: built.reduce((n, epic) => n + epic.unlinked, 0),
|
|
839
958
|
orphanPlans: built.reduce((n, epic) => n + epic.orphans.length, 0),
|
|
959
|
+
ignoredFiles: built.reduce((n, epic) => n + epic.ignored.length, 0),
|
|
840
960
|
},
|
|
841
961
|
unreadable,
|
|
842
962
|
}
|
|
@@ -863,9 +983,8 @@ window.__ModuleLoader__.load({
|
|
|
863
983
|
}
|
|
864
984
|
|
|
865
985
|
const rank = (status) => {
|
|
866
|
-
if (status === null) return STATUS_ORDER.length - 1
|
|
867
986
|
const index = STATUS_ORDER.indexOf(status)
|
|
868
|
-
return index === -1 ? STATUS_ORDER.length -
|
|
987
|
+
return index === -1 ? STATUS_ORDER.length - 1 : index
|
|
869
988
|
}
|
|
870
989
|
|
|
871
990
|
return [...groups]
|
|
@@ -927,44 +1046,49 @@ window.__ModuleLoader__.load({
|
|
|
927
1046
|
const unverifiable = []
|
|
928
1047
|
const where = initiative.slug
|
|
929
1048
|
|
|
930
|
-
// A
|
|
931
|
-
//
|
|
932
|
-
//
|
|
1049
|
+
// A ticket file the store cannot classify is invisible to `tickets.py`. The
|
|
1050
|
+
// store tells leaves from plans by frontmatter type, so a `*-plan.md` whose
|
|
1051
|
+
// type is not `story`/`spike`/`bug` and which names no ticket is neither --
|
|
1052
|
+
// its ticket then reports as backlog however finished the work is. Every
|
|
933
1053
|
// number the tooling prints is wrong while every file on disk looks correct.
|
|
934
|
-
|
|
935
|
-
|
|
1054
|
+
const ignored = initiative.epics.flatMap((epic) =>
|
|
1055
|
+
epic.ignored.map((path) => ({ epic, path })))
|
|
1056
|
+
if (ignored.length > 0) {
|
|
1057
|
+
const example = ignored[0]
|
|
936
1058
|
gaps.push(gap({
|
|
937
1059
|
id: 'L1',
|
|
938
|
-
title: '
|
|
1060
|
+
title: 'ticket files the store cannot read',
|
|
939
1061
|
severity: 'high',
|
|
940
1062
|
subject: where,
|
|
941
1063
|
subjectKind: 'initiative',
|
|
942
1064
|
evidence:
|
|
943
|
-
`${
|
|
944
|
-
+ `
|
|
1065
|
+
`${ignored.length} file(s) in a ticket folder are neither a leaf ticket nor a plan, `
|
|
1066
|
+
+ `so the store ignores them and reports their tickets as backlog.`,
|
|
945
1067
|
action: ask(
|
|
946
|
-
`In ${where},
|
|
947
|
-
+
|
|
948
|
-
+ `
|
|
1068
|
+
`In ${where}, ${ignored.length} ticket file(s) are invisible to the store: `
|
|
1069
|
+
+ `${example.path} and others. Each needs \`type: story\` (or spike/bug) to be a leaf, `
|
|
1070
|
+
+ `or a \`ticket: <id>\` key to be a plan. Then re-run \`tickets.py status\`.`,
|
|
949
1071
|
),
|
|
950
1072
|
}))
|
|
951
1073
|
}
|
|
952
1074
|
|
|
953
|
-
//
|
|
954
|
-
//
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
unverifiable.push({
|
|
1075
|
+
// A leaf the index does not list is a ticket the store knows and the plan does
|
|
1076
|
+
// not, which is the inverse of an orphan: work exists that nothing schedules.
|
|
1077
|
+
const unlisted = initiative.epics.flatMap((epic) =>
|
|
1078
|
+
epic.tickets.filter((ticket) => ticket.unlisted === true).map((ticket) => ({ epic, ticket })))
|
|
1079
|
+
if (unlisted.length > 0) {
|
|
1080
|
+
gaps.push(gap({
|
|
960
1081
|
id: 'L2',
|
|
961
|
-
title: '
|
|
1082
|
+
title: 'ticket files no index entry lists',
|
|
1083
|
+
severity: 'medium',
|
|
962
1084
|
subject: where,
|
|
963
1085
|
subjectKind: 'initiative',
|
|
964
|
-
evidence: `${
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
1086
|
+
evidence: `${unlisted.length} leaf ticket(s) exist on disk with no entry in any ticket index.`,
|
|
1087
|
+
action: ask(
|
|
1088
|
+
`These tickets in ${where} have a file but no index entry: `
|
|
1089
|
+
+ `${unlisted.map(({ ticket }) => ticket.ref).join(', ')}. Add the entry or remove the file.`,
|
|
1090
|
+
),
|
|
1091
|
+
}))
|
|
968
1092
|
}
|
|
969
1093
|
|
|
970
1094
|
// A plan file nothing claims is either a renamed ticket or leftover work.
|
|
@@ -990,7 +1114,7 @@ window.__ModuleLoader__.load({
|
|
|
990
1114
|
// starts lying.
|
|
991
1115
|
const odd = initiative.epics.flatMap((epic) =>
|
|
992
1116
|
epic.tickets
|
|
993
|
-
.filter((ticket) => ticket.status
|
|
1117
|
+
.filter((ticket) => ticket.status === 'unknown')
|
|
994
1118
|
.map((ticket) => ({ epic, ticket })))
|
|
995
1119
|
if (odd.length > 0) {
|
|
996
1120
|
unverifiable.push({
|
|
@@ -999,8 +1123,8 @@ window.__ModuleLoader__.load({
|
|
|
999
1123
|
subject: where,
|
|
1000
1124
|
subjectKind: 'initiative',
|
|
1001
1125
|
evidence: `${odd.length} ticket(s) carry a status this board does not recognise, so they are not counted either way.`,
|
|
1002
|
-
tickets: odd.map(({ ticket }) => `${ticket.ref} (${ticket.
|
|
1003
|
-
action: ask(`These statuses are unrecognised: ${odd.map(({ ticket }) => `${ticket.ref}=${ticket.
|
|
1126
|
+
tickets: odd.map(({ ticket }) => `${ticket.ref} (${ticket.word})`),
|
|
1127
|
+
action: ask(`These statuses are unrecognised: ${odd.map(({ ticket }) => `${ticket.ref}=${ticket.word}`).join(', ')}. Confirm what they mean.`),
|
|
1004
1128
|
})
|
|
1005
1129
|
}
|
|
1006
1130
|
|
|
@@ -1008,7 +1132,7 @@ window.__ModuleLoader__.load({
|
|
|
1008
1132
|
// migration artefact: the folder was closed, the work inside it was not.
|
|
1009
1133
|
for (const epic of initiative.epics) {
|
|
1010
1134
|
if (epic.status === null || !SETTLED.has(epic.status)) continue
|
|
1011
|
-
const open = epic.tickets.filter((ticket) =>
|
|
1135
|
+
const open = epic.tickets.filter((ticket) => !SETTLED.has(ticket.status))
|
|
1012
1136
|
if (open.length === 0) continue
|
|
1013
1137
|
gaps.push(gap({
|
|
1014
1138
|
id: 'C1',
|
|
@@ -1026,7 +1150,7 @@ window.__ModuleLoader__.load({
|
|
|
1026
1150
|
|
|
1027
1151
|
if (initiative.status !== null && SETTLED.has(initiative.status)) {
|
|
1028
1152
|
const open = initiative.epics.flatMap((epic) =>
|
|
1029
|
-
epic.tickets.filter((ticket) =>
|
|
1153
|
+
epic.tickets.filter((ticket) => !SETTLED.has(ticket.status)))
|
|
1030
1154
|
if (open.length > 0) {
|
|
1031
1155
|
gaps.push(gap({
|
|
1032
1156
|
id: 'C2',
|
|
@@ -1182,15 +1306,18 @@ window.__ModuleLoader__.load({
|
|
|
1182
1306
|
tryList(io, join(folder, 'tickets'), signal),
|
|
1183
1307
|
])
|
|
1184
1308
|
|
|
1309
|
+
// Every markdown file in the folder: the store tells leaves from plans by
|
|
1310
|
+
// their frontmatter, and a file name is not a reliable signal for that. A
|
|
1311
|
+
// `*-plan.md` whose type is not a leaf type and which names no ticket is
|
|
1312
|
+
// neither, and reading only the plans would hide that instead of reporting it.
|
|
1185
1313
|
const names = listing
|
|
1186
|
-
.filter((entry) => entry.type === 'file' && entry.name.endsWith(
|
|
1314
|
+
.filter((entry) => entry.type === 'file' && entry.name.endsWith('.md'))
|
|
1187
1315
|
.map((entry) => entry.name)
|
|
1188
1316
|
.sort()
|
|
1189
1317
|
|
|
1190
|
-
// A
|
|
1191
|
-
//
|
|
1192
|
-
|
|
1193
|
-
const plans = await mapLimit(names, READ_CONCURRENCY, async (name) => {
|
|
1318
|
+
// A file the directory listing named and the reader could not open is a real
|
|
1319
|
+
// problem -- it exists and its state is hidden -- so this read is recorded.
|
|
1320
|
+
const files = await mapLimit(names, READ_CONCURRENCY, async (name) => {
|
|
1194
1321
|
const path = join(join(folder, 'tickets'), name)
|
|
1195
1322
|
const text = await tryRead(io, path, signal)
|
|
1196
1323
|
if (text === null) {
|
|
@@ -1200,7 +1327,7 @@ window.__ModuleLoader__.load({
|
|
|
1200
1327
|
return { path, text }
|
|
1201
1328
|
})
|
|
1202
1329
|
|
|
1203
|
-
return { ...row, folder, ticket, storyIndex,
|
|
1330
|
+
return { ...row, folder, ticket, storyIndex, files: files.filter(Boolean) }
|
|
1204
1331
|
}
|
|
1205
1332
|
|
|
1206
1333
|
/**
|
|
@@ -2749,6 +2876,7 @@ line-height:16px;font-weight:700;white-space:nowrap;animation:bmad-sb-tip-in 140
|
|
|
2749
2876
|
'noEpics': 'This initiative declares no epics.',
|
|
2750
2877
|
'unreadableNote': '{count} file(s) could not be read; the board may be incomplete.',
|
|
2751
2878
|
'status.blocked': 'Blocked',
|
|
2879
|
+
'status.backlog': 'Not started',
|
|
2752
2880
|
'status.in-progress': 'In progress',
|
|
2753
2881
|
'status.review': 'In review',
|
|
2754
2882
|
'status.built': 'Built',
|
|
@@ -2813,6 +2941,7 @@ line-height:16px;font-weight:700;white-space:nowrap;animation:bmad-sb-tip-in 140
|
|
|
2813
2941
|
'noEpics': '\u6b64\u5021\u8bae\u5c1a\u672a\u58f0\u660e\u4efb\u4f55\u53f2\u8bd7\u3002',
|
|
2814
2942
|
'unreadableNote': '\u6709 {count} \u4e2a\u6587\u4ef6\u65e0\u6cd5\u8bfb\u53d6\uff0c\u770b\u677f\u53ef\u80fd\u4e0d\u5b8c\u6574\u3002',
|
|
2815
2943
|
'status.blocked': '\u53d7\u963b',
|
|
2944
|
+
'status.backlog': '\u5c1a\u672a\u5f00\u59cb',
|
|
2816
2945
|
'status.in-progress': '\u8fdb\u884c\u4e2d',
|
|
2817
2946
|
'status.review': '\u5ba1\u67e5\u4e2d',
|
|
2818
2947
|
'status.built': '\u5df2\u6784\u5efa',
|
package/package.json
CHANGED