super-backlog 1.2.0 → 1.3.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 +1 -1
- package/dist/dashboard/data.js +120 -17
- package/dist/dashboard/metrics.js +97 -0
- package/dist/models/dashboard-api.js +35 -6
- package/dist/models/install.js +9 -2
- package/dist/templates/dashboard.html +747 -153
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -97,7 +97,7 @@ The router is fully owned by super-backlog and removed by `sbl uninstall`. See t
|
|
|
97
97
|
|
|
98
98
|
## Project Dashboard
|
|
99
99
|
|
|
100
|
-
`sbl dashboard` starts a local hub that serves
|
|
100
|
+
`sbl dashboard` starts a local hub that serves an HTS-style cockpit (light/dark theme toggle) rendered from your Backlog data in eight sections — Board & Quick Actions, Status (KPI tiles, donut, and an aging strip for open tasks), Feature Cycle (pipeline stepper plus an Up Next / Blocked flow view from task dependencies), Milestones, Drafts (click a card for details), Tasks (sortable/filterable table; click a row to open a modal detail view with acceptance criteria and dependencies), Activity (a 26-week calendar heatmap; click a day for its tasks), and Decisions & Docs. Glossary tooltips explain domain terms inline; extend or override them project-wide via `backlog/docs/glossary.md` (`## Term` heading plus the text below it). Typefaces load from Google Fonts with full system fallbacks — the one external resource; everything else is inline, and the dashboard still renders offline. Bookmark `http://127.0.0.1:6428/p/<project_name>/`. The hub watches `backlog/`, regenerates on change, and serves on port `6428`; connected browser tabs reload automatically via Server-Sent Events. A second repo's `sbl dashboard` attaches to the same hub. `Ctrl+C` in the hub terminal stops all projects.
|
|
101
101
|
|
|
102
102
|
### Keeping it fresh
|
|
103
103
|
|
package/dist/dashboard/data.js
CHANGED
|
@@ -5,6 +5,7 @@ import { basename, join } from 'node:path';
|
|
|
5
5
|
import { resolveBacklogBin, runCapture } from '../lib/run.js';
|
|
6
6
|
import { isNewerVersion } from '../lib/version-check.js';
|
|
7
7
|
import { readSimpleKeys } from '../lib/yamlmini.js';
|
|
8
|
+
import { computeKpis } from './metrics.js';
|
|
8
9
|
function isRecord(v) {
|
|
9
10
|
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
10
11
|
}
|
|
@@ -52,17 +53,29 @@ function normalizeAcs(value) {
|
|
|
52
53
|
}
|
|
53
54
|
return out;
|
|
54
55
|
}
|
|
56
|
+
function firstAssignee(t) {
|
|
57
|
+
const list = t['assignees'];
|
|
58
|
+
if (Array.isArray(list)) {
|
|
59
|
+
for (const entry of list) {
|
|
60
|
+
const name = asString(entry);
|
|
61
|
+
if (name !== undefined)
|
|
62
|
+
return name;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return asString(t['assignee']);
|
|
66
|
+
}
|
|
55
67
|
export function normalizeTasks(rawTasks) {
|
|
56
68
|
return rawTasks.map((t) => ({
|
|
57
69
|
id: asString(t['id']) ?? '',
|
|
58
70
|
title: asString(t['title']) ?? '(untitled)',
|
|
59
71
|
status: asString(t['status']) ?? 'Unknown',
|
|
60
72
|
priority: asString(t['priority']),
|
|
61
|
-
assignee:
|
|
62
|
-
|
|
73
|
+
assignee: firstAssignee(t),
|
|
74
|
+
created: asString(t['createdAt']) ?? asString(t['created_at']) ?? asString(t['created']),
|
|
75
|
+
updated: asString(t['updatedAt']) ?? asString(t['updated_at']) ?? asString(t['updated']),
|
|
63
76
|
milestone: asString(t['milestone']),
|
|
64
77
|
description: asString(t['description']),
|
|
65
|
-
acs: normalizeAcs(t['acceptance_criteria']),
|
|
78
|
+
acs: normalizeAcs(t['acceptanceCriteria'] ?? t['acceptance_criteria']),
|
|
66
79
|
}));
|
|
67
80
|
}
|
|
68
81
|
export function computeStatuses(tasks) {
|
|
@@ -129,20 +142,24 @@ function shiftDay(day, deltaDays) {
|
|
|
129
142
|
const [y, mo, d] = day.split('-').map(Number);
|
|
130
143
|
return new Date(Date.UTC(y, mo - 1, d) + deltaDays * 86400000).toISOString().slice(0, 10);
|
|
131
144
|
}
|
|
132
|
-
|
|
145
|
+
export const ACTIVITY_DAYS = 182;
|
|
146
|
+
/** Bucket tasks into exactly ACTIVITY_DAYS UTC daily buckets ending at `today`, oldest first. */
|
|
133
147
|
export function computeActivity(rawTasks, today) {
|
|
134
|
-
const
|
|
148
|
+
const byDay = new Map();
|
|
135
149
|
for (const t of rawTasks) {
|
|
136
|
-
const day = isoDay(asString(t['updated_at']) ?? asString(t['updated'])) ??
|
|
137
|
-
isoDay(asString(t['created_at'])) ??
|
|
150
|
+
const day = isoDay(asString(t['updatedAt']) ?? asString(t['updated_at']) ?? asString(t['updated'])) ??
|
|
151
|
+
isoDay(asString(t['createdAt']) ?? asString(t['created_at'])) ??
|
|
138
152
|
today;
|
|
139
|
-
|
|
153
|
+
const ids = byDay.get(day) ?? [];
|
|
154
|
+
ids.push(asString(t['id']) ?? '');
|
|
155
|
+
byDay.set(day, ids);
|
|
140
156
|
}
|
|
141
|
-
const start = shiftDay(today, -
|
|
157
|
+
const start = shiftDay(today, -(ACTIVITY_DAYS - 1));
|
|
142
158
|
const out = [];
|
|
143
|
-
for (let i = 0; i <
|
|
159
|
+
for (let i = 0; i < ACTIVITY_DAYS; i++) {
|
|
144
160
|
const date = shiftDay(start, i);
|
|
145
|
-
|
|
161
|
+
const ids = byDay.get(date) ?? [];
|
|
162
|
+
out.push({ date, count: ids.length, ids });
|
|
146
163
|
}
|
|
147
164
|
return out;
|
|
148
165
|
}
|
|
@@ -219,13 +236,33 @@ function readProjectGlossary(cwd) {
|
|
|
219
236
|
}
|
|
220
237
|
}
|
|
221
238
|
function readDraftFile(path) {
|
|
222
|
-
const keys = readSimpleKeys(path, [
|
|
239
|
+
const keys = readSimpleKeys(path, [
|
|
240
|
+
'id', 'title', 'status', 'priority', 'assignee',
|
|
241
|
+
'created_date', 'updated_date', 'created', 'updated',
|
|
242
|
+
]);
|
|
223
243
|
const id = asString(keys.id);
|
|
224
244
|
const title = asString(keys.title);
|
|
225
245
|
const status = asString(keys.status);
|
|
226
246
|
if (!id || !title || !status)
|
|
227
247
|
return null;
|
|
228
|
-
|
|
248
|
+
let detail = { acs: [] };
|
|
249
|
+
try {
|
|
250
|
+
detail = parseTaskFile(readFileSync(path, 'utf8'));
|
|
251
|
+
}
|
|
252
|
+
catch {
|
|
253
|
+
// keys-only draft when the file cannot be re-read
|
|
254
|
+
}
|
|
255
|
+
return {
|
|
256
|
+
id,
|
|
257
|
+
title,
|
|
258
|
+
status,
|
|
259
|
+
description: detail.description,
|
|
260
|
+
priority: asString(keys.priority),
|
|
261
|
+
assignee: asString(keys.assignee),
|
|
262
|
+
created: asString(keys['created_date']) ?? asString(keys['created']),
|
|
263
|
+
updated: asString(keys['updated_date']) ?? asString(keys['updated']),
|
|
264
|
+
acs: detail.acs,
|
|
265
|
+
};
|
|
229
266
|
}
|
|
230
267
|
export function readDrafts(cwd) {
|
|
231
268
|
const draftsDir = join(cwd, 'backlog', 'drafts');
|
|
@@ -265,6 +302,67 @@ function readProjectIdentity(cwd) {
|
|
|
265
302
|
const description = asString(cfg['description']) ?? asString(pkg?.['description']) ?? '';
|
|
266
303
|
return { name, description };
|
|
267
304
|
}
|
|
305
|
+
function sectionBetween(content, begin, end) {
|
|
306
|
+
const from = content.indexOf(begin);
|
|
307
|
+
if (from === -1)
|
|
308
|
+
return undefined;
|
|
309
|
+
const to = content.indexOf(end, from + begin.length);
|
|
310
|
+
if (to === -1)
|
|
311
|
+
return undefined;
|
|
312
|
+
const text = content.slice(from + begin.length, to).trim();
|
|
313
|
+
return text === '' ? undefined : text;
|
|
314
|
+
}
|
|
315
|
+
/** Parse one backlog task markdown file via its explicit section markers. */
|
|
316
|
+
export function parseTaskFile(content) {
|
|
317
|
+
const idMatch = /^id:\s*['"]?([^'"\r\n]+)['"]?\s*$/m.exec(content);
|
|
318
|
+
const description = sectionBetween(content, '<!-- SECTION:DESCRIPTION:BEGIN -->', '<!-- SECTION:DESCRIPTION:END -->');
|
|
319
|
+
const acs = [];
|
|
320
|
+
const acBlock = sectionBetween(content, '<!-- AC:BEGIN -->', '<!-- AC:END -->');
|
|
321
|
+
if (acBlock !== undefined) {
|
|
322
|
+
for (const line of acBlock.split(/\r?\n/)) {
|
|
323
|
+
const m = /^-\s*\[( |x|X)\]\s*(?:#\d+\s*)?(.+)$/.exec(line.trim());
|
|
324
|
+
if (m)
|
|
325
|
+
acs.push({ text: m[2].trim(), checked: m[1].toLowerCase() === 'x' });
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return { id: idMatch?.[1]?.trim(), description, acs };
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* Fill description/ACs from backlog/tasks/*.md where `task list --json`
|
|
332
|
+
* (schemaVersion 1) does not carry them. The CLI stays the source of truth
|
|
333
|
+
* for the list and statuses; files only supply missing detail fields.
|
|
334
|
+
*/
|
|
335
|
+
export function enrichTasksFromFiles(cwd, tasks) {
|
|
336
|
+
const dir = join(cwd, 'backlog', 'tasks');
|
|
337
|
+
let files;
|
|
338
|
+
try {
|
|
339
|
+
files = readdirSync(dir).filter((f) => f.endsWith('.md'));
|
|
340
|
+
}
|
|
341
|
+
catch {
|
|
342
|
+
return tasks;
|
|
343
|
+
}
|
|
344
|
+
const byId = new Map();
|
|
345
|
+
for (const file of files) {
|
|
346
|
+
try {
|
|
347
|
+
const detail = parseTaskFile(readFileSync(join(dir, file), 'utf8'));
|
|
348
|
+
if (detail.id !== undefined)
|
|
349
|
+
byId.set(detail.id.toUpperCase(), detail);
|
|
350
|
+
}
|
|
351
|
+
catch {
|
|
352
|
+
// unreadable file -> no enrichment for that task
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
return tasks.map((t) => {
|
|
356
|
+
const detail = byId.get(t.id.toUpperCase());
|
|
357
|
+
if (!detail)
|
|
358
|
+
return t;
|
|
359
|
+
return {
|
|
360
|
+
...t,
|
|
361
|
+
description: t.description ?? detail.description,
|
|
362
|
+
acs: t.acs.length > 0 ? t.acs : detail.acs,
|
|
363
|
+
};
|
|
364
|
+
});
|
|
365
|
+
}
|
|
268
366
|
function readLatestVersion(home, kitVersion) {
|
|
269
367
|
try {
|
|
270
368
|
const raw = readFileSync(join(home, '.super-backlog', 'version-check.json'), 'utf8');
|
|
@@ -281,6 +379,7 @@ export function collectDashboardData(cwd, opts) {
|
|
|
281
379
|
const today = opts.today && /^\d{4}-\d{2}-\d{2}$/.test(opts.today.trim())
|
|
282
380
|
? opts.today.trim()
|
|
283
381
|
: new Date().toISOString().slice(0, 10);
|
|
382
|
+
const activity = computeActivity([], today);
|
|
284
383
|
const base = {
|
|
285
384
|
project: readProjectIdentity(cwd),
|
|
286
385
|
generatedAt: new Date().toISOString(),
|
|
@@ -291,8 +390,9 @@ export function collectDashboardData(cwd, opts) {
|
|
|
291
390
|
tasks: [],
|
|
292
391
|
deps: [],
|
|
293
392
|
drafts: readDrafts(cwd),
|
|
294
|
-
activity
|
|
393
|
+
activity,
|
|
295
394
|
glossary: mergeGlossary(readProjectGlossary(cwd)),
|
|
395
|
+
kpis: computeKpis([], [], activity, today),
|
|
296
396
|
source: 'fallback-empty',
|
|
297
397
|
};
|
|
298
398
|
try {
|
|
@@ -303,14 +403,17 @@ export function collectDashboardData(cwd, opts) {
|
|
|
303
403
|
if (res.status !== 0)
|
|
304
404
|
return base;
|
|
305
405
|
const rawTasks = parseTasksJson(res.stdout);
|
|
306
|
-
const tasks = normalizeTasks(rawTasks);
|
|
406
|
+
const tasks = enrichTasksFromFiles(cwd, normalizeTasks(rawTasks));
|
|
407
|
+
const deps = computeDeps(rawTasks);
|
|
408
|
+
const taskActivity = computeActivity(rawTasks, today);
|
|
307
409
|
return {
|
|
308
410
|
...base,
|
|
309
411
|
tasks,
|
|
310
412
|
statuses: computeStatuses(tasks),
|
|
311
413
|
milestones: computeMilestones(tasks),
|
|
312
|
-
deps
|
|
313
|
-
activity:
|
|
414
|
+
deps,
|
|
415
|
+
activity: taskActivity,
|
|
416
|
+
kpis: computeKpis(tasks, deps, taskActivity, today),
|
|
314
417
|
source: 'backlog-json',
|
|
315
418
|
};
|
|
316
419
|
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
function isDone(status) {
|
|
2
|
+
const s = status.toLowerCase();
|
|
3
|
+
return s === 'done' || s === 'complete' || s === 'completed';
|
|
4
|
+
}
|
|
5
|
+
function isWip(status) {
|
|
6
|
+
const s = status.toLowerCase();
|
|
7
|
+
return s.includes('progress') || s.includes('review');
|
|
8
|
+
}
|
|
9
|
+
function utcDay(value) {
|
|
10
|
+
if (!value)
|
|
11
|
+
return null;
|
|
12
|
+
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(value.trim());
|
|
13
|
+
if (m)
|
|
14
|
+
return Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
|
|
15
|
+
const t = Date.parse(value);
|
|
16
|
+
if (Number.isNaN(t))
|
|
17
|
+
return null;
|
|
18
|
+
const d = new Date(t);
|
|
19
|
+
return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate());
|
|
20
|
+
}
|
|
21
|
+
const DAY_MS = 86_400_000;
|
|
22
|
+
export function computeKpis(tasks, deps, activity, today) {
|
|
23
|
+
const todayMs = utcDay(today) ?? Date.UTC(1970, 0, 1);
|
|
24
|
+
const total = tasks.length;
|
|
25
|
+
const doneTasks = tasks.filter((t) => isDone(t.status));
|
|
26
|
+
const done = doneTasks.length;
|
|
27
|
+
const open = total - done;
|
|
28
|
+
const progressPct = total > 0 ? Math.round((done / total) * 100) : 0;
|
|
29
|
+
const doneInWindow = (fromDaysAgo, toDaysAgo) => doneTasks.filter((t) => {
|
|
30
|
+
const d = utcDay(t.updated);
|
|
31
|
+
if (d === null)
|
|
32
|
+
return false;
|
|
33
|
+
const age = (todayMs - d) / DAY_MS;
|
|
34
|
+
return age >= toDaysAgo && age < fromDaysAgo;
|
|
35
|
+
}).length;
|
|
36
|
+
const velocity7 = doneInWindow(7, 0);
|
|
37
|
+
const velocityPrev7 = doneInWindow(14, 7);
|
|
38
|
+
let forecastDate = null;
|
|
39
|
+
if (velocity7 > 0 && open > 0) {
|
|
40
|
+
const daysNeeded = Math.ceil((open / velocity7) * 7);
|
|
41
|
+
forecastDate = new Date(todayMs + daysNeeded * DAY_MS).toISOString().slice(0, 10);
|
|
42
|
+
}
|
|
43
|
+
const byId = new Map(tasks.map((t) => [t.id, t]));
|
|
44
|
+
const unresolved = new Set();
|
|
45
|
+
for (const dep of deps) {
|
|
46
|
+
const from = byId.get(dep.from);
|
|
47
|
+
const to = byId.get(dep.to);
|
|
48
|
+
if (from && !isDone(from.status) && to && !isDone(to.status))
|
|
49
|
+
unresolved.add(dep.from);
|
|
50
|
+
}
|
|
51
|
+
const openTasks = tasks.filter((t) => !isDone(t.status));
|
|
52
|
+
const wip = openTasks.filter((t) => isWip(t.status)).length;
|
|
53
|
+
const blocked = openTasks.filter((t) => t.status.toLowerCase().includes('block') || unresolved.has(t.id)).length;
|
|
54
|
+
const ages = [];
|
|
55
|
+
for (const t of openTasks) {
|
|
56
|
+
const d = utcDay(t.created) ?? utcDay(t.updated);
|
|
57
|
+
if (d === null)
|
|
58
|
+
continue;
|
|
59
|
+
ages.push({ id: t.id, days: Math.max(0, Math.round((todayMs - d) / DAY_MS)) });
|
|
60
|
+
}
|
|
61
|
+
ages.sort((a, b) => b.days - a.days);
|
|
62
|
+
const oldest = ages[0] ?? null;
|
|
63
|
+
let medianOpenAgeDays = null;
|
|
64
|
+
if (ages.length > 0) {
|
|
65
|
+
const mid = Math.floor(ages.length / 2);
|
|
66
|
+
medianOpenAgeDays =
|
|
67
|
+
ages.length % 2 === 1 ? ages[mid].days : Math.round((ages[mid - 1].days + ages[mid].days) / 2);
|
|
68
|
+
}
|
|
69
|
+
const activityTotal30 = activity.slice(-30).reduce((sum, b) => sum + b.count, 0);
|
|
70
|
+
const windowTotal = activity.reduce((sum, b) => sum + b.count, 0);
|
|
71
|
+
const activityAvgPerWeek = activity.length > 0 ? Math.round((windowTotal / (activity.length / 7)) * 10) / 10 : 0;
|
|
72
|
+
const perWeekday = [0, 0, 0, 0, 0, 0, 0];
|
|
73
|
+
for (const b of activity) {
|
|
74
|
+
const d = utcDay(b.date);
|
|
75
|
+
if (d !== null)
|
|
76
|
+
perWeekday[new Date(d).getUTCDay()] += b.count;
|
|
77
|
+
}
|
|
78
|
+
const maxWeekday = Math.max(...perWeekday);
|
|
79
|
+
const busiestWeekday = maxWeekday > 0 ? perWeekday.indexOf(maxWeekday) : null;
|
|
80
|
+
let streakDays = 0;
|
|
81
|
+
let i = activity.length - 1;
|
|
82
|
+
if (i >= 0 && activity[i].count === 0)
|
|
83
|
+
i--; // today may still be empty
|
|
84
|
+
while (i >= 0 && activity[i].count > 0) {
|
|
85
|
+
streakDays++;
|
|
86
|
+
i--;
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
total, done, open, progressPct,
|
|
90
|
+
velocity7, velocityPrev7, forecastDate,
|
|
91
|
+
wip, blocked,
|
|
92
|
+
oldestOpenId: oldest ? oldest.id : null,
|
|
93
|
+
oldestOpenDays: oldest ? oldest.days : null,
|
|
94
|
+
medianOpenAgeDays,
|
|
95
|
+
activityTotal30, activityAvgPerWeek, busiestWeekday, streakDays,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
@@ -1,18 +1,47 @@
|
|
|
1
|
+
// src/models/dashboard-api.ts
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
1
4
|
import { loadConfig } from './config.js';
|
|
2
5
|
import { discoverModels } from './discovery.js';
|
|
3
|
-
|
|
6
|
+
import { writeResolvedTiers, writeRouterConfig } from './install.js';
|
|
7
|
+
function sendJson(res, status, body) {
|
|
8
|
+
res.writeHead(status, { 'content-type': 'application/json' });
|
|
9
|
+
res.end(JSON.stringify(body));
|
|
10
|
+
}
|
|
11
|
+
function routerInstalled(cwd) {
|
|
12
|
+
return existsSync(join(cwd, '.super-backlog', 'models.json'));
|
|
13
|
+
}
|
|
14
|
+
export function createModelApiHandler(cwd, deps = {}) {
|
|
15
|
+
const discover = deps.discover ?? discoverModels;
|
|
4
16
|
return async (req, res) => {
|
|
5
17
|
const url = req.url ?? '/';
|
|
6
18
|
const method = req.method ?? 'GET';
|
|
7
19
|
if (method === 'GET' && url === '/api/models') {
|
|
8
|
-
res
|
|
9
|
-
|
|
20
|
+
sendJson(res, 200, { config: loadConfig(cwd), installed: routerInstalled(cwd), status: 'ok' });
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
if (method === 'POST' && (url === '/api/models/enable' || url === '/api/models/disable')) {
|
|
24
|
+
const enabled = url === '/api/models/enable';
|
|
25
|
+
try {
|
|
26
|
+
writeRouterConfig(cwd, enabled);
|
|
27
|
+
sendJson(res, 200, { ok: true, config: loadConfig(cwd), installed: routerInstalled(cwd) });
|
|
28
|
+
}
|
|
29
|
+
catch (err) {
|
|
30
|
+
sendJson(res, 500, { ok: false, message: err instanceof Error ? err.message : String(err) });
|
|
31
|
+
}
|
|
10
32
|
return;
|
|
11
33
|
}
|
|
12
34
|
if (method === 'POST' && url === '/api/models/discover') {
|
|
13
|
-
const result = await
|
|
14
|
-
|
|
15
|
-
|
|
35
|
+
const result = await discover(cwd);
|
|
36
|
+
if (result) {
|
|
37
|
+
try {
|
|
38
|
+
writeResolvedTiers(cwd, result);
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
// discovery result still returned; the modal just won't remember it
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
sendJson(res, 200, result ?? { error: 'discovery failed' });
|
|
16
45
|
return;
|
|
17
46
|
}
|
|
18
47
|
res.writeHead(404, { 'content-type': 'text/plain' });
|
package/dist/models/install.js
CHANGED
|
@@ -6,13 +6,20 @@ import { loadConfig } from './config.js';
|
|
|
6
6
|
const CONFIG_DIR = '.super-backlog';
|
|
7
7
|
const CONFIG_FILE = 'models.json';
|
|
8
8
|
export function writeRouterConfig(cwd, enabled) {
|
|
9
|
+
writeConfigPatch(cwd, { enabled });
|
|
10
|
+
return true;
|
|
11
|
+
}
|
|
12
|
+
/** Persist discovered tiers so the dashboard shows them across sessions. */
|
|
13
|
+
export function writeResolvedTiers(cwd, resolved) {
|
|
14
|
+
writeConfigPatch(cwd, { resolved });
|
|
15
|
+
}
|
|
16
|
+
function writeConfigPatch(cwd, patch) {
|
|
9
17
|
const dir = join(cwd, CONFIG_DIR);
|
|
10
18
|
const path = join(dir, CONFIG_FILE);
|
|
11
19
|
if (!existsSync(dir)) {
|
|
12
20
|
mkdirSync(dir, { recursive: true });
|
|
13
21
|
}
|
|
14
22
|
const current = loadConfig(cwd);
|
|
15
|
-
const next = { ...current,
|
|
23
|
+
const next = { ...current, ...patch };
|
|
16
24
|
atomicWrite(path, `${JSON.stringify(next, null, 2)}\n`);
|
|
17
|
-
return true;
|
|
18
25
|
}
|