infinity-harness 2.3.0 → 2.4.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/CHANGELOG.md +85 -1
- package/README.md +87 -22
- package/extensions/infinity-harness/index.ts +223 -39
- package/package.json +2 -2
- package/src/approval.ts +15 -5
- package/src/core/config.ts +44 -1
- package/src/core/init.ts +22 -1
- package/src/core/paths.ts +25 -0
- package/src/core/settings.ts +141 -12
- package/src/core/types.ts +49 -1
- package/src/intake.ts +124 -115
- package/src/remote.ts +7 -1
- package/src/ui/config.ts +6 -0
- package/src/ui/dashboard.ts +90 -27
- package/src/ui/display.ts +267 -0
- package/src/ui/planTree.ts +38 -16
- package/src/ui/widget.ts +50 -18
- package/src/ui/wizard.ts +257 -90
- package/src/workflow.ts +309 -0
package/src/ui/dashboard.ts
CHANGED
|
@@ -39,6 +39,8 @@ import { getPhaseOrder } from "../core/phases.ts";
|
|
|
39
39
|
import { statusGlyph } from "./widget.ts";
|
|
40
40
|
import { UNICODE_GLYPHS } from "./theme.ts";
|
|
41
41
|
import { groupPlan, type PlanGoalGroup, type PlanSprintGroup } from "./planTree.ts";
|
|
42
|
+
import { defaultDisplay, normalizeDisplay } from "./display.ts";
|
|
43
|
+
import type { DisplayPolicy } from "../core/types.ts";
|
|
42
44
|
|
|
43
45
|
export type DashboardState = {
|
|
44
46
|
list: FeatureList;
|
|
@@ -59,6 +61,11 @@ export type DashboardState = {
|
|
|
59
61
|
sessions?: number | null;
|
|
60
62
|
/** Which goal pass this is, out of how many. */
|
|
61
63
|
goalPass?: { current: number; max: number } | null;
|
|
64
|
+
/**
|
|
65
|
+
* What this reader has asked to see — the same policy the terminal widget
|
|
66
|
+
* reads, so a level turned off in one is off in the other.
|
|
67
|
+
*/
|
|
68
|
+
display?: DisplayPolicy | null;
|
|
62
69
|
};
|
|
63
70
|
|
|
64
71
|
// ── escaping ────────────────────────────────────────────────────────────────
|
|
@@ -492,7 +499,8 @@ function depLabel(task: FlatTask, indexByKey: ReadonlyMap<string, number>): stri
|
|
|
492
499
|
* cannot fit, so hiding four of the five plan levels here made it a worse copy
|
|
493
500
|
* of the widget rather than the place you go for the full picture.
|
|
494
501
|
*/
|
|
495
|
-
function renderSubtasks(task: FlatTask): string {
|
|
502
|
+
function renderSubtasks(task: FlatTask, mode: DisplayPolicy["levels"]["subtask"], active: boolean): string {
|
|
503
|
+
if (mode === "none" || (mode === "active" && !active)) return "";
|
|
496
504
|
const subs = Array.isArray(task.subtasks) ? task.subtasks : [];
|
|
497
505
|
if (subs.length === 0) return "";
|
|
498
506
|
const items = subs
|
|
@@ -506,7 +514,11 @@ function renderSubtasks(task: FlatTask): string {
|
|
|
506
514
|
return `<ul class="subs">${items}</ul>`;
|
|
507
515
|
}
|
|
508
516
|
|
|
509
|
-
function renderTaskRow(
|
|
517
|
+
function renderTaskRow(
|
|
518
|
+
task: FlatTask,
|
|
519
|
+
indexByKey: ReadonlyMap<string, number>,
|
|
520
|
+
display: DisplayPolicy,
|
|
521
|
+
): string {
|
|
510
522
|
const status = task.status;
|
|
511
523
|
const cls = STATUS_CLASS[status];
|
|
512
524
|
const isActive = status === "in_progress" || status === "rework";
|
|
@@ -522,9 +534,9 @@ function renderTaskRow(task: FlatTask, indexByKey: ReadonlyMap<string, number>):
|
|
|
522
534
|
<div class="task-line">
|
|
523
535
|
<span class="task-desc">${esc(task.description || task.compositeKey)}</span>
|
|
524
536
|
${difficulty}
|
|
525
|
-
${depLabel(task, indexByKey)}
|
|
537
|
+
${display.dependencies ? depLabel(task, indexByKey) : ""}
|
|
526
538
|
</div>
|
|
527
|
-
${renderSubtasks(task)}
|
|
539
|
+
${renderSubtasks(task, display.levels.subtask, isActive)}
|
|
528
540
|
</td>
|
|
529
541
|
<td class="cell-status"><span class="pill pill-${cls}">${esc(STATUS_LABEL[status])}</span></td>
|
|
530
542
|
</tr>`;
|
|
@@ -536,6 +548,7 @@ function renderFeature(
|
|
|
536
548
|
indexByKey: ReadonlyMap<string, number>,
|
|
537
549
|
sprintName: string | null,
|
|
538
550
|
goalName: string | null,
|
|
551
|
+
display: DisplayPolicy,
|
|
539
552
|
): string {
|
|
540
553
|
const counts = countByStatus(tasks);
|
|
541
554
|
const total = tasks.length;
|
|
@@ -547,14 +560,23 @@ function renderFeature(
|
|
|
547
560
|
feature.passes === true ? `<span class="chip chip-complete">verified</span>` : "",
|
|
548
561
|
].join("");
|
|
549
562
|
|
|
550
|
-
const body =
|
|
551
|
-
?
|
|
563
|
+
const body = !display.levels.task
|
|
564
|
+
? ""
|
|
565
|
+
: total
|
|
566
|
+
? `<div class="table-wrap">
|
|
552
567
|
<table class="tasks">
|
|
553
568
|
<thead><tr><th scope="col" class="cell-n">#</th><th scope="col">Task</th><th scope="col" class="cell-status">Status</th></tr></thead>
|
|
554
|
-
<tbody>${tasks.map((t) => renderTaskRow(t, indexByKey)).join("")}</tbody>
|
|
569
|
+
<tbody>${tasks.map((t) => renderTaskRow(t, indexByKey, display)).join("")}</tbody>
|
|
555
570
|
</table>
|
|
556
571
|
</div>`
|
|
557
|
-
|
|
572
|
+
: `<p class="empty-inline">No tasks planned for this feature yet.</p>`;
|
|
573
|
+
|
|
574
|
+
const criteria =
|
|
575
|
+
display.criteria && Array.isArray(feature.criteria) && feature.criteria.length
|
|
576
|
+
? `<ul class="criteria">${feature.criteria
|
|
577
|
+
.map((c) => `<li>${esc(String(c))}</li>`)
|
|
578
|
+
.join("")}</ul>`
|
|
579
|
+
: "";
|
|
558
580
|
|
|
559
581
|
return `<section class="card feature${complete ? " is-complete" : ""}">
|
|
560
582
|
<div class="feature-head">
|
|
@@ -564,11 +586,12 @@ function renderFeature(
|
|
|
564
586
|
${chips}
|
|
565
587
|
</div>
|
|
566
588
|
<div class="feature-progress">
|
|
567
|
-
|
|
589
|
+
${display.counts ? `<span class="feature-count mono">${esc(String(counts.complete))}/${esc(String(total))}</span>` : ""}
|
|
568
590
|
${meter(counts, total)}
|
|
569
591
|
</div>
|
|
570
592
|
</div>
|
|
571
593
|
${feature.description ? `<p class="feature-desc">${esc(feature.description)}</p>` : ""}
|
|
594
|
+
${criteria}
|
|
572
595
|
${body}
|
|
573
596
|
</section>`;
|
|
574
597
|
}
|
|
@@ -585,9 +608,10 @@ function renderGoalGroup(
|
|
|
585
608
|
tasksByFeature: ReadonlyMap<string, FlatTask[]>,
|
|
586
609
|
indexByKey: ReadonlyMap<string, number>,
|
|
587
610
|
show: { showGoal: boolean; showSprints: boolean },
|
|
611
|
+
display: DisplayPolicy,
|
|
588
612
|
): string {
|
|
589
613
|
const sprints = group.sprints
|
|
590
|
-
.map((sg) => renderSprintGroup(sg, tasksByFeature, indexByKey, show.showSprints))
|
|
614
|
+
.map((sg) => renderSprintGroup(sg, tasksByFeature, indexByKey, show.showSprints, display))
|
|
591
615
|
.join("");
|
|
592
616
|
|
|
593
617
|
if (!show.showGoal || !group.goal) return sprints;
|
|
@@ -597,7 +621,7 @@ function renderGoalGroup(
|
|
|
597
621
|
<span class="tier-kind">goal</span>
|
|
598
622
|
<span class="tier-name">${esc(group.goal.title ?? group.goal.id ?? "")}</span>
|
|
599
623
|
<span class="mono faint">${esc(group.goal.id ?? "")}</span>
|
|
600
|
-
|
|
624
|
+
${display.counts ? `<span class="tier-count mono">${esc(String(group.done))}/${esc(String(group.total))}</span>` : ""}
|
|
601
625
|
</summary>
|
|
602
626
|
<div class="tier-body">${sprints}</div>
|
|
603
627
|
</details>`;
|
|
@@ -608,10 +632,16 @@ function renderSprintGroup(
|
|
|
608
632
|
tasksByFeature: ReadonlyMap<string, FlatTask[]>,
|
|
609
633
|
indexByKey: ReadonlyMap<string, number>,
|
|
610
634
|
showSprints: boolean,
|
|
635
|
+
display: DisplayPolicy,
|
|
611
636
|
): string {
|
|
612
|
-
const features =
|
|
613
|
-
|
|
614
|
-
|
|
637
|
+
const features = display.levels.feature
|
|
638
|
+
? group.features
|
|
639
|
+
.map((f) => renderFeature(f, tasksByFeature.get(f.id) ?? [], indexByKey, null, null, display))
|
|
640
|
+
.join("")
|
|
641
|
+
: // Hiding the feature card must not hide its tasks: they move up into the
|
|
642
|
+
// sprint, which is what "hide features" has to mean on a page whose whole
|
|
643
|
+
// job is to show the plan.
|
|
644
|
+
renderLooseTasks(group.features.flatMap((f) => tasksByFeature.get(f.id) ?? []), indexByKey, display);
|
|
615
645
|
|
|
616
646
|
if (!showSprints || !group.sprint) return features;
|
|
617
647
|
|
|
@@ -620,12 +650,29 @@ function renderSprintGroup(
|
|
|
620
650
|
<span class="tier-kind">sprint</span>
|
|
621
651
|
<span class="tier-name">${esc(group.sprint.name ?? group.sprint.id ?? "")}</span>
|
|
622
652
|
<span class="mono faint">${esc(group.sprint.id ?? "")}</span>
|
|
623
|
-
|
|
653
|
+
${display.counts ? `<span class="tier-count mono">${esc(String(group.done))}/${esc(String(group.total))}</span>` : ""}
|
|
624
654
|
</summary>
|
|
625
655
|
<div class="tier-body">${features}</div>
|
|
626
656
|
</details>`;
|
|
627
657
|
}
|
|
628
658
|
|
|
659
|
+
/** Tasks with no feature card above them, for the templates that hide features. */
|
|
660
|
+
function renderLooseTasks(
|
|
661
|
+
tasks: readonly FlatTask[],
|
|
662
|
+
indexByKey: ReadonlyMap<string, number>,
|
|
663
|
+
display: DisplayPolicy,
|
|
664
|
+
): string {
|
|
665
|
+
if (!display.levels.task || tasks.length === 0) return "";
|
|
666
|
+
return `<section class="card feature">
|
|
667
|
+
<div class="table-wrap">
|
|
668
|
+
<table class="tasks">
|
|
669
|
+
<thead><tr><th scope="col" class="cell-n">#</th><th scope="col">Task</th><th scope="col" class="cell-status">Status</th></tr></thead>
|
|
670
|
+
<tbody>${tasks.map((t) => renderTaskRow(t, indexByKey, display)).join("")}</tbody>
|
|
671
|
+
</table>
|
|
672
|
+
</div>
|
|
673
|
+
</section>`;
|
|
674
|
+
}
|
|
675
|
+
|
|
629
676
|
function renderEmptyPlan(phase: Phase | null): string {
|
|
630
677
|
const where = phase ? `The harness is in ${esc(phase)}.` : "The harness has not started a phase yet.";
|
|
631
678
|
return `<section class="card empty">
|
|
@@ -858,6 +905,11 @@ body{
|
|
|
858
905
|
.alert-active{color:var(--t-active);background:rgba(var(--rgb-active),.10);border-color:rgba(var(--rgb-active),.28)}
|
|
859
906
|
.alert-quiet{color:var(--muted);background:var(--surface-2);border-color:var(--border)}
|
|
860
907
|
|
|
908
|
+
/* -- acceptance criteria -------------------------------------------------- */
|
|
909
|
+
.criteria{margin:10px 0 0;padding:0 0 0 18px;color:var(--muted);font-size:13px}
|
|
910
|
+
.criteria li{margin:3px 0;overflow-wrap:anywhere}
|
|
911
|
+
.criteria li::marker{color:var(--t-accent)}
|
|
912
|
+
|
|
861
913
|
/* -- gate ----------------------------------------------------------------- */
|
|
862
914
|
.gate-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px}
|
|
863
915
|
.gate-phase{font-size:15px;font-weight:600;text-transform:uppercase;letter-spacing:.04em;overflow-wrap:anywhere}
|
|
@@ -1134,14 +1186,21 @@ export function renderDashboard(state: DashboardState): string {
|
|
|
1134
1186
|
// chips on a feature card — throws away the only structure that tells you
|
|
1135
1187
|
// whether the run is nearly done with something or scattered across
|
|
1136
1188
|
// everything.
|
|
1189
|
+
const display = normalizeDisplay(state.display ?? defaultDisplay());
|
|
1137
1190
|
const groups = groupPlan(list);
|
|
1138
1191
|
const body = features.length
|
|
1139
1192
|
? groups
|
|
1140
1193
|
.map((group) =>
|
|
1141
|
-
renderGoalGroup(
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1194
|
+
renderGoalGroup(
|
|
1195
|
+
group,
|
|
1196
|
+
tasksByFeature,
|
|
1197
|
+
indexByKey,
|
|
1198
|
+
{
|
|
1199
|
+
showGoal: display.levels.goal && goals.length > 0,
|
|
1200
|
+
showSprints: display.levels.sprint && sprints.length > 0,
|
|
1201
|
+
},
|
|
1202
|
+
display,
|
|
1203
|
+
),
|
|
1145
1204
|
)
|
|
1146
1205
|
.join("")
|
|
1147
1206
|
: renderEmptyPlan(state.phase);
|
|
@@ -1168,14 +1227,18 @@ export function renderDashboard(state: DashboardState): string {
|
|
|
1168
1227
|
<div id="app">
|
|
1169
1228
|
<div class="page">
|
|
1170
1229
|
${renderMasthead(state.phase, paused, progress.percent, state.baseRevision, badges)}
|
|
1171
|
-
${renderGoals(goals)}
|
|
1172
|
-
${renderRail(state.phase, state.enabledPhases, paused)}
|
|
1173
|
-
${
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1230
|
+
${display.levels.goal ? renderGoals(goals) : ""}
|
|
1231
|
+
${display.rail ? renderRail(state.phase, state.enabledPhases, paused) : ""}
|
|
1232
|
+
${
|
|
1233
|
+
display.alerts
|
|
1234
|
+
? renderAlerts(counts, paused, state.retries, gate, {
|
|
1235
|
+
awaitingApproval: state.awaitingApproval ?? null,
|
|
1236
|
+
sessions: state.sessions ?? null,
|
|
1237
|
+
goalPass: state.goalPass ?? null,
|
|
1238
|
+
})
|
|
1239
|
+
: ""
|
|
1240
|
+
}
|
|
1241
|
+
${display.progress ? renderProgress(counts, progress.tasksTotal, progress.featuresDone, progress.featuresTotal) : ""}
|
|
1179
1242
|
${renderGate(gate)}
|
|
1180
1243
|
${body}
|
|
1181
1244
|
<footer class="foot">
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* infinity-harness — display templates: what the surfaces actually draw.
|
|
3
|
+
*
|
|
4
|
+
* The plan has five levels, and shipping all five to everyone was the wrong
|
|
5
|
+
* answer for the same reason shipping two was: two people watching the same
|
|
6
|
+
* run want different things on screen. One works in sprints and never opens a
|
|
7
|
+
* subtask. The next has no sprints at all and lives in the subtask list. A run
|
|
8
|
+
* on a forty-task plan wants the shape; a run on one feature wants the detail.
|
|
9
|
+
*
|
|
10
|
+
* So the levels are a setting, three templates cover the common shapes, and
|
|
11
|
+
* anything else is a template the person builds and names. The terminal widget
|
|
12
|
+
* and the web dashboard read the same one, so what you configure once is what
|
|
13
|
+
* you see in both.
|
|
14
|
+
*
|
|
15
|
+
* Templates live with the *person* — `~/.pi/agent/infinity-harness/` — because
|
|
16
|
+
* how you like to read a plan does not change when you change project.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { DisplayPolicy } from "../core/types.ts";
|
|
20
|
+
import { userDisplayPath } from "../core/paths.ts";
|
|
21
|
+
import { readJsonSafe, writeJsonAtomic, ensureDir } from "../core/fsx.ts";
|
|
22
|
+
import { dirname } from "node:path";
|
|
23
|
+
|
|
24
|
+
export type DisplayTemplate = {
|
|
25
|
+
id: string;
|
|
26
|
+
name: string;
|
|
27
|
+
description: string;
|
|
28
|
+
builtIn: boolean;
|
|
29
|
+
policy: DisplayPolicy;
|
|
30
|
+
savedAt?: string;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/** Everything on, at a size a terminal can hold. */
|
|
34
|
+
export function defaultDisplay(): DisplayPolicy {
|
|
35
|
+
return {
|
|
36
|
+
preset: "focus",
|
|
37
|
+
levels: { goal: true, sprint: true, feature: true, task: true, subtask: "active" },
|
|
38
|
+
counts: true,
|
|
39
|
+
dependencies: true,
|
|
40
|
+
rail: true,
|
|
41
|
+
progress: true,
|
|
42
|
+
alerts: true,
|
|
43
|
+
criteria: true,
|
|
44
|
+
taskWindow: 9,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const focus: DisplayPolicy = defaultDisplay();
|
|
49
|
+
|
|
50
|
+
const everything: DisplayPolicy = {
|
|
51
|
+
...defaultDisplay(),
|
|
52
|
+
preset: "everything",
|
|
53
|
+
levels: { goal: true, sprint: true, feature: true, task: true, subtask: "all" },
|
|
54
|
+
taskWindow: 14,
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The shape of the run, without the work.
|
|
59
|
+
*
|
|
60
|
+
* For a plan too big to read: goals and sprints and features with their
|
|
61
|
+
* counts, and no tasks at all. You come here to see which branch is stuck.
|
|
62
|
+
*/
|
|
63
|
+
const overview: DisplayPolicy = {
|
|
64
|
+
...defaultDisplay(),
|
|
65
|
+
preset: "overview",
|
|
66
|
+
levels: { goal: true, sprint: true, feature: true, task: false, subtask: "none" },
|
|
67
|
+
dependencies: false,
|
|
68
|
+
criteria: false,
|
|
69
|
+
taskWindow: 12,
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The work, and nothing else.
|
|
74
|
+
*
|
|
75
|
+
* For someone who already knows the plan and wants the next thing to do.
|
|
76
|
+
* No grouping rows, no meters, no rail.
|
|
77
|
+
*/
|
|
78
|
+
const worklist: DisplayPolicy = {
|
|
79
|
+
...defaultDisplay(),
|
|
80
|
+
preset: "worklist",
|
|
81
|
+
levels: { goal: false, sprint: false, feature: false, task: true, subtask: "active" },
|
|
82
|
+
counts: false,
|
|
83
|
+
rail: false,
|
|
84
|
+
progress: true,
|
|
85
|
+
alerts: true,
|
|
86
|
+
criteria: false,
|
|
87
|
+
taskWindow: 12,
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
export const BUILTIN_DISPLAYS: DisplayTemplate[] = [
|
|
91
|
+
{
|
|
92
|
+
id: "focus",
|
|
93
|
+
name: "focus",
|
|
94
|
+
description: "The default: every level, with subtasks on the task being worked.",
|
|
95
|
+
builtIn: true,
|
|
96
|
+
policy: focus,
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
id: "everything",
|
|
100
|
+
name: "everything",
|
|
101
|
+
description: "All five levels, every subtask on every task, and a taller window.",
|
|
102
|
+
builtIn: true,
|
|
103
|
+
policy: everything,
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
id: "overview",
|
|
107
|
+
name: "overview",
|
|
108
|
+
description: "Goals, sprints and features with their counts. No tasks — the shape, not the work.",
|
|
109
|
+
builtIn: true,
|
|
110
|
+
policy: overview,
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
id: "worklist",
|
|
114
|
+
name: "worklist",
|
|
115
|
+
description: "Tasks only. No grouping rows, no rail — for when you already know the plan.",
|
|
116
|
+
builtIn: true,
|
|
117
|
+
policy: worklist,
|
|
118
|
+
},
|
|
119
|
+
];
|
|
120
|
+
|
|
121
|
+
export function builtInDisplay(id: string): DisplayTemplate | null {
|
|
122
|
+
return BUILTIN_DISPLAYS.find((d) => d.id === id) ?? null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ── the saved store ─────────────────────────────────────────────────────────
|
|
126
|
+
|
|
127
|
+
type SavedStore = { version: string; displays: DisplayTemplate[] };
|
|
128
|
+
|
|
129
|
+
export function slugify(name: string): string {
|
|
130
|
+
return String(name ?? "")
|
|
131
|
+
.trim()
|
|
132
|
+
.toLowerCase()
|
|
133
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
134
|
+
.replace(/^-+|-+$/g, "")
|
|
135
|
+
.slice(0, 48);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function loadSavedDisplays(env?: NodeJS.ProcessEnv): DisplayTemplate[] {
|
|
139
|
+
const store = readJsonSafe<SavedStore | null>(userDisplayPath(env), null);
|
|
140
|
+
const list = Array.isArray(store?.displays) ? store.displays : [];
|
|
141
|
+
return list
|
|
142
|
+
.filter((d): d is DisplayTemplate => typeof d?.id === "string" && typeof d?.name === "string")
|
|
143
|
+
.map((d) => ({ ...d, builtIn: false, policy: normalizeDisplay(d.policy) }));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function listDisplays(env?: NodeJS.ProcessEnv): DisplayTemplate[] {
|
|
147
|
+
return [...BUILTIN_DISPLAYS, ...loadSavedDisplays(env)];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function findDisplay(id: string, env?: NodeJS.ProcessEnv): DisplayTemplate | null {
|
|
151
|
+
return listDisplays(env).find((d) => d.id === id) ?? null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export type SaveResult = { ok: boolean; error: string | null; template?: DisplayTemplate };
|
|
155
|
+
|
|
156
|
+
export function saveDisplay(
|
|
157
|
+
input: { name: string; description?: string; policy: DisplayPolicy },
|
|
158
|
+
env?: NodeJS.ProcessEnv,
|
|
159
|
+
): SaveResult {
|
|
160
|
+
const name = String(input.name ?? "").trim();
|
|
161
|
+
if (!name) return { ok: false, error: "A template needs a name." };
|
|
162
|
+
const id = slugify(name);
|
|
163
|
+
if (!id) return { ok: false, error: `"${name}" has no letters or digits in it.` };
|
|
164
|
+
if (builtInDisplay(id)) {
|
|
165
|
+
return { ok: false, error: `"${name}" is a built-in template. Pick another name.` };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const policy = { ...normalizeDisplay(input.policy), preset: id };
|
|
169
|
+
const template: DisplayTemplate = {
|
|
170
|
+
id,
|
|
171
|
+
name,
|
|
172
|
+
description: (input.description ?? "").trim() || describeDisplay(policy),
|
|
173
|
+
builtIn: false,
|
|
174
|
+
policy,
|
|
175
|
+
savedAt: new Date().toISOString(),
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
const existing = loadSavedDisplays(env).filter((d) => d.id !== id);
|
|
179
|
+
const path = userDisplayPath(env);
|
|
180
|
+
try {
|
|
181
|
+
ensureDir(dirname(path));
|
|
182
|
+
writeJsonAtomic(path, { version: "1", displays: [...existing, template] } satisfies SavedStore);
|
|
183
|
+
} catch (e) {
|
|
184
|
+
return { ok: false, error: e instanceof Error ? e.message : String(e) };
|
|
185
|
+
}
|
|
186
|
+
return { ok: true, error: null, template };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function deleteDisplay(id: string, env?: NodeJS.ProcessEnv): SaveResult {
|
|
190
|
+
if (builtInDisplay(id)) return { ok: false, error: "Built-in templates cannot be deleted." };
|
|
191
|
+
const remaining = loadSavedDisplays(env).filter((d) => d.id !== id);
|
|
192
|
+
const path = userDisplayPath(env);
|
|
193
|
+
try {
|
|
194
|
+
ensureDir(dirname(path));
|
|
195
|
+
writeJsonAtomic(path, { version: "1", displays: remaining } satisfies SavedStore);
|
|
196
|
+
} catch (e) {
|
|
197
|
+
return { ok: false, error: e instanceof Error ? e.message : String(e) };
|
|
198
|
+
}
|
|
199
|
+
return { ok: true, error: null };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ── normalising ─────────────────────────────────────────────────────────────
|
|
203
|
+
|
|
204
|
+
const SUBTASK_MODES = new Set(["none", "active", "all"]);
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Repair a policy read from disk.
|
|
208
|
+
*
|
|
209
|
+
* Every field is read on every render, in a lifecycle hook, so a hand-edited
|
|
210
|
+
* config with `levels: "yes"` in it must produce a widget rather than an
|
|
211
|
+
* exception that takes the session down.
|
|
212
|
+
*/
|
|
213
|
+
export function normalizeDisplay(raw: unknown): DisplayPolicy {
|
|
214
|
+
const base = defaultDisplay();
|
|
215
|
+
if (typeof raw !== "object" || raw === null) return base;
|
|
216
|
+
const r = raw as Partial<DisplayPolicy> & { levels?: Partial<DisplayPolicy["levels"]> };
|
|
217
|
+
const bool = (v: unknown, fallback: boolean): boolean => (typeof v === "boolean" ? v : fallback);
|
|
218
|
+
|
|
219
|
+
const window = typeof r.taskWindow === "number" && Number.isFinite(r.taskWindow) ? r.taskWindow : base.taskWindow;
|
|
220
|
+
|
|
221
|
+
return {
|
|
222
|
+
preset: typeof r.preset === "string" && r.preset.trim() ? r.preset.trim() : base.preset,
|
|
223
|
+
levels: {
|
|
224
|
+
goal: bool(r.levels?.goal, base.levels.goal),
|
|
225
|
+
sprint: bool(r.levels?.sprint, base.levels.sprint),
|
|
226
|
+
feature: bool(r.levels?.feature, base.levels.feature),
|
|
227
|
+
task: bool(r.levels?.task, base.levels.task),
|
|
228
|
+
subtask: SUBTASK_MODES.has(String(r.levels?.subtask))
|
|
229
|
+
? (r.levels?.subtask as DisplayPolicy["levels"]["subtask"])
|
|
230
|
+
: base.levels.subtask,
|
|
231
|
+
},
|
|
232
|
+
counts: bool(r.counts, base.counts),
|
|
233
|
+
dependencies: bool(r.dependencies, base.dependencies),
|
|
234
|
+
rail: bool(r.rail, base.rail),
|
|
235
|
+
progress: bool(r.progress, base.progress),
|
|
236
|
+
alerts: bool(r.alerts, base.alerts),
|
|
237
|
+
criteria: bool(r.criteria, base.criteria),
|
|
238
|
+
// A window of zero is a widget with no plan in it, which nobody wants and
|
|
239
|
+
// which a typo can produce.
|
|
240
|
+
taskWindow: Math.max(3, Math.min(60, Math.round(window))),
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Which template a policy currently matches, if any. */
|
|
245
|
+
export function matchDisplay(policy: DisplayPolicy, env?: NodeJS.ProcessEnv): DisplayTemplate | null {
|
|
246
|
+
const same = (a: DisplayPolicy, b: DisplayPolicy): boolean =>
|
|
247
|
+
JSON.stringify({ ...normalizeDisplay(a), preset: "" }) ===
|
|
248
|
+
JSON.stringify({ ...normalizeDisplay(b), preset: "" });
|
|
249
|
+
return listDisplays(env).find((d) => same(d.policy, policy)) ?? null;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export function describeDisplay(policy: DisplayPolicy): string {
|
|
253
|
+
const p = normalizeDisplay(policy);
|
|
254
|
+
const on = (["goal", "sprint", "feature", "task"] as const).filter((k) => p.levels[k]);
|
|
255
|
+
const subtasks =
|
|
256
|
+
p.levels.subtask === "all" ? "all subtasks" : p.levels.subtask === "active" ? "active subtasks" : null;
|
|
257
|
+
const shown = [...on, ...(subtasks ? [subtasks] : [])];
|
|
258
|
+
return shown.length ? shown.join(" · ") : "nothing — every level is hidden";
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** One line for a menu row: `focus · goal · sprint · feature · task · active subtasks`. */
|
|
262
|
+
export function summarizeDisplay(policy: DisplayPolicy, env?: NodeJS.ProcessEnv): string {
|
|
263
|
+
const p = normalizeDisplay(policy);
|
|
264
|
+
const named = matchDisplay(p, env);
|
|
265
|
+
const label = named ? named.name : `${p.preset} (edited)`;
|
|
266
|
+
return `${label} · ${describeDisplay(p)}`;
|
|
267
|
+
}
|
package/src/ui/planTree.ts
CHANGED
|
@@ -55,6 +55,14 @@ export type PlanTreeOptions = {
|
|
|
55
55
|
* goal and no sprints should not spend two rows saying so.
|
|
56
56
|
*/
|
|
57
57
|
collapseTrivial?: boolean;
|
|
58
|
+
/**
|
|
59
|
+
* Which levels the reader has asked to see.
|
|
60
|
+
*
|
|
61
|
+
* A hidden level does not hide what is under it: turning off sprints on a
|
|
62
|
+
* plan organised into sprints must still show the features, one indent
|
|
63
|
+
* shallower, or the setting silently deletes half the plan from view.
|
|
64
|
+
*/
|
|
65
|
+
levels?: Partial<Record<PlanLevel, boolean>>;
|
|
58
66
|
};
|
|
59
67
|
|
|
60
68
|
function taskCounts(tasks: Task[] | undefined): { done: number; total: number } {
|
|
@@ -185,35 +193,48 @@ export function buildPlanRows(
|
|
|
185
193
|
): PlanRow[] {
|
|
186
194
|
const collapse = options.collapseTrivial !== false;
|
|
187
195
|
const groups = groupPlan(list);
|
|
196
|
+
const wants = (level: PlanLevel): boolean => options.levels?.[level] !== false;
|
|
188
197
|
|
|
189
198
|
// A single goal is the run's headline and the surface draws it separately.
|
|
190
|
-
const showGoals =
|
|
191
|
-
|
|
199
|
+
const showGoals =
|
|
200
|
+
wants("goal") &&
|
|
201
|
+
(collapse ? groups.filter((g) => g.goal !== null).length > 1 : groups.some((g) => g.goal));
|
|
202
|
+
const showSprints = wants("sprint") && groups.some((g) => g.sprints.some((sg) => sg.sprint !== null));
|
|
192
203
|
|
|
193
204
|
const rows: PlanRow[] = [];
|
|
194
205
|
let taskIndex = 0;
|
|
195
206
|
|
|
196
207
|
const emitFeature = (feature: Feature, depth: number): void => {
|
|
197
208
|
const counts = taskCounts(feature.tasks);
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
+
let taskDepth = depth;
|
|
210
|
+
if (wants("feature")) {
|
|
211
|
+
rows.push({
|
|
212
|
+
level: "feature",
|
|
213
|
+
depth,
|
|
214
|
+
id: feature.id,
|
|
215
|
+
label: feature.id,
|
|
216
|
+
title: feature.name ?? feature.id,
|
|
217
|
+
status: null,
|
|
218
|
+
done: counts.done,
|
|
219
|
+
total: counts.total,
|
|
220
|
+
criteria: Array.isArray(feature.criteria) ? feature.criteria : undefined,
|
|
221
|
+
});
|
|
222
|
+
} else {
|
|
223
|
+
// Hiding the feature row must not hide its tasks — nor indent them as
|
|
224
|
+
// though the row were still there.
|
|
225
|
+
taskDepth = Math.max(0, depth - 1);
|
|
226
|
+
}
|
|
209
227
|
|
|
210
228
|
for (const task of feature.tasks ?? []) {
|
|
229
|
+
// Numbered even when hidden: `← #3` has to keep meaning the same task
|
|
230
|
+
// whatever the reader has chosen to look at.
|
|
211
231
|
taskIndex += 1;
|
|
232
|
+
if (!wants("task")) continue;
|
|
212
233
|
const id = task.key ?? `${feature.id}/${task.id}`;
|
|
213
234
|
const active = activeTaskId !== null && (id === activeTaskId || task.id === activeTaskId);
|
|
214
235
|
rows.push({
|
|
215
236
|
level: "task",
|
|
216
|
-
depth:
|
|
237
|
+
depth: taskDepth + 1,
|
|
217
238
|
id,
|
|
218
239
|
label: String(taskIndex),
|
|
219
240
|
title: task.description ?? id,
|
|
@@ -233,8 +254,8 @@ export function buildPlanRows(
|
|
|
233
254
|
// one, so a task that is plainly in progress counts too.
|
|
234
255
|
const subs = Array.isArray(task.subtasks) ? task.subtasks : [];
|
|
235
256
|
const working = task.status === "in_progress" || task.status === "rework";
|
|
236
|
-
const show = options.expandSubtasks || active || working;
|
|
237
|
-
if (show && subs.length) rows.push(...subtaskRows(task, id,
|
|
257
|
+
const show = wants("subtask") && (options.expandSubtasks || active || working);
|
|
258
|
+
if (show && subs.length) rows.push(...subtaskRows(task, id, taskDepth + 2, subs));
|
|
238
259
|
}
|
|
239
260
|
};
|
|
240
261
|
|
|
@@ -255,6 +276,7 @@ export function buildPlanRows(
|
|
|
255
276
|
}
|
|
256
277
|
for (const sg of group.sprints) {
|
|
257
278
|
let featureDepth = depth;
|
|
279
|
+
// Every feature row hidden means the tasks sit where the features were.
|
|
258
280
|
if (showSprints && sg.sprint) {
|
|
259
281
|
rows.push({
|
|
260
282
|
level: "sprint",
|