@warren-dean/code-kit 0.7.0 → 0.8.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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +25 -0
- package/README.md +14 -3
- package/bin/code-kit.mjs +94 -0
- package/hooks/guard-bash.mjs +24 -2
- package/hooks/lib/adapters/context-graph.mjs +18 -19
- package/hooks/lib/journal.mjs +224 -0
- package/hooks/lib/loop-state.mjs +26 -0
- package/hooks/lib/plan.mjs +8 -1
- package/hooks/lib/storymap.mjs +155 -0
- package/hooks/mod/codemap.mjs +328 -0
- package/hooks/mod/decisions.mjs +146 -0
- package/hooks/mod/mission.mjs +116 -0
- package/hooks/mod/panes.mjs +469 -34
- package/hooks/mod/register.mjs +196 -2
- package/hooks/mod/register.test.ts +436 -18
- package/hooks/mod/trail.mjs +152 -0
- package/hooks/mod/view.mjs +320 -110
- package/package.json +1 -1
- package/skills/init/SKILL.md +3 -3
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// The mission band (docs/specs/14-mission-and-maps.md, MISSION-1 to MISSION-3), the pure parts: the
|
|
2
|
+
// goal, what the lead is doing now, the loop's next step, each agent at work with its tool, and a line
|
|
3
|
+
// from another tool only when it has something to say. The band draws these above what needs the person.
|
|
4
|
+
import { stepLabel } from './loop.mjs';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* MISSION-1: the milestone being built: the plan's first milestone with a story not done (else its
|
|
8
|
+
* last), with how many of its stories are done: { name, done, total }, or null when the plan has none.
|
|
9
|
+
*/
|
|
10
|
+
export function goalOf(stories = []) {
|
|
11
|
+
const named = stories.filter((s) => s.milestone);
|
|
12
|
+
if (!named.length) return null;
|
|
13
|
+
const open = named.find((s) => s.state !== 'done') ?? named.at(-1);
|
|
14
|
+
const mine = named.filter((s) => s.milestone === open.milestone);
|
|
15
|
+
return {
|
|
16
|
+
name: open.milestone,
|
|
17
|
+
done: mine.filter((s) => s.state === 'done').length,
|
|
18
|
+
total: mine.length,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The words for the loop's next step, from `code-kit next --json`. */
|
|
23
|
+
export function nextText(step) {
|
|
24
|
+
if (!step) return null;
|
|
25
|
+
if (step.step === 'done') return 'nothing: every story is done';
|
|
26
|
+
if (step.step === 'wait') return `waiting: ${step.why}`;
|
|
27
|
+
const then = (step.then ?? []).map((t) => stepLabel(t));
|
|
28
|
+
const label = ['spec-design', 'init', 'check'].includes(step.step)
|
|
29
|
+
? `${step.step} (yours to run)`
|
|
30
|
+
: stepLabel(step);
|
|
31
|
+
return `${label}${then.length ? `, then ${then.join(', ')}` : ''}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** At most this many agents get a line of their own; the rest are counted. */
|
|
35
|
+
const AGENT_LINES = 5;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* MISSION-1 to MISSION-3: the band's mission rows, or none while nothing is happening (no lead turn,
|
|
39
|
+
* no agent at work, the loop not started). Each row is { kind, mission: true, label, text, ... }:
|
|
40
|
+
* goal; now (with Pause or Resume while the loop runs); next; agents, with one entry per lane at work
|
|
41
|
+
* ({ lane, tool, ago, level }); and a line per adapter fact worth the person's attention.
|
|
42
|
+
*/
|
|
43
|
+
export function missionLines({
|
|
44
|
+
state,
|
|
45
|
+
loop = null,
|
|
46
|
+
leadBusy = false,
|
|
47
|
+
live = {},
|
|
48
|
+
nextStep = null,
|
|
49
|
+
combined = null,
|
|
50
|
+
}) {
|
|
51
|
+
if (state?.kind !== 'ok') return [];
|
|
52
|
+
const working = state.lanes.filter((l) => l.name !== 'lead' && l.active && live[l.name]);
|
|
53
|
+
const loopOn = Boolean(loop?.started) && !['off', 'stopped'].includes(loop?.state);
|
|
54
|
+
if (!leadBusy && !working.length && !loopOn) return [];
|
|
55
|
+
const rows = [];
|
|
56
|
+
const goal = goalOf(state.stories);
|
|
57
|
+
if (goal)
|
|
58
|
+
rows.push({
|
|
59
|
+
kind: 'goal',
|
|
60
|
+
label: 'goal',
|
|
61
|
+
text: `${goal.name} · ${goal.done} of ${goal.total} ${goal.total === 1 ? 'story' : 'stories'} done`,
|
|
62
|
+
});
|
|
63
|
+
const lead = leadBusy
|
|
64
|
+
? `lead ${live.lead?.tool ? `at work: ${live.lead.tool}` : 'at work'}`
|
|
65
|
+
: 'lead idle';
|
|
66
|
+
const loopWord =
|
|
67
|
+
loop?.state === 'paused'
|
|
68
|
+
? 'loop paused'
|
|
69
|
+
: loop?.state === 'off'
|
|
70
|
+
? 'loop off'
|
|
71
|
+
: loop?.state === 'done'
|
|
72
|
+
? 'milestone done'
|
|
73
|
+
: loopOn
|
|
74
|
+
? `loop ${loop.autonomy === 'propose' ? 'proposing' : 'on'}${loop.last ? ` · last: ${loop.last}` : ''}`
|
|
75
|
+
: null;
|
|
76
|
+
rows.push({
|
|
77
|
+
kind: 'now',
|
|
78
|
+
label: 'now',
|
|
79
|
+
text: [lead, loopWord].filter(Boolean).join(' · '),
|
|
80
|
+
actions:
|
|
81
|
+
loop?.state === 'paused'
|
|
82
|
+
? [{ id: 'resume', label: 'Resume' }]
|
|
83
|
+
: loopOn && loop?.state === 'running'
|
|
84
|
+
? [{ id: 'pause', label: 'Pause' }]
|
|
85
|
+
: [],
|
|
86
|
+
});
|
|
87
|
+
const next = nextText(nextStep);
|
|
88
|
+
if (next && loop?.state !== 'done') rows.push({ kind: 'next', label: 'next', text: next });
|
|
89
|
+
if (working.length)
|
|
90
|
+
rows.push({
|
|
91
|
+
kind: 'agents',
|
|
92
|
+
label: 'agents',
|
|
93
|
+
text: `${working.length} at work`,
|
|
94
|
+
agents: working.slice(0, AGENT_LINES).map((l) => ({
|
|
95
|
+
lane: l.name,
|
|
96
|
+
tool: live[l.name].tool,
|
|
97
|
+
ago: live[l.name].ago,
|
|
98
|
+
level: live[l.name].level ?? null,
|
|
99
|
+
})),
|
|
100
|
+
more: Math.max(0, working.length - AGENT_LINES),
|
|
101
|
+
});
|
|
102
|
+
// MISSION-3: another tool's line only when it isn't zero: edits made before the file was understood.
|
|
103
|
+
const unread = Object.entries(combined ?? {}).filter(([, f]) => f.without > 0);
|
|
104
|
+
if (unread.length)
|
|
105
|
+
rows.push({
|
|
106
|
+
kind: 'facts',
|
|
107
|
+
label: 'context',
|
|
108
|
+
text: unread
|
|
109
|
+
.map(
|
|
110
|
+
([lane, f]) =>
|
|
111
|
+
`${lane} edited ${f.without} file${f.without === 1 ? '' : 's'} without understanding ${f.without === 1 ? 'it' : 'them'}`,
|
|
112
|
+
)
|
|
113
|
+
.join(' · '),
|
|
114
|
+
});
|
|
115
|
+
return rows.map((r) => ({ actions: [], ...r, mission: true }));
|
|
116
|
+
}
|
package/hooks/mod/panes.mjs
CHANGED
|
@@ -1,12 +1,20 @@
|
|
|
1
1
|
// Panes v2 (docs/specs/11-panes.md): the Lanes pane's frame. A counts header, tabs, what needs the
|
|
2
2
|
// person, the tab's body and the keys, as pure functions of what the mod already holds. The Lanes
|
|
3
3
|
// tab's body is view.mjs's lanesPane; the Queue and Usage tabs reuse its sections.
|
|
4
|
+
import { trailRows as trailRowsDrawn, trailSvg } from './trail.mjs';
|
|
5
|
+
import {
|
|
6
|
+
MAP_LEGEND,
|
|
7
|
+
cardOf,
|
|
8
|
+
mapRows as codeRows,
|
|
9
|
+
mapStoryOf,
|
|
10
|
+
mapSvg,
|
|
11
|
+
neighboursOf,
|
|
12
|
+
} from './codemap.mjs';
|
|
4
13
|
import {
|
|
5
14
|
NOT_CODE_KIT,
|
|
6
15
|
STATE_COLOR,
|
|
7
16
|
ago,
|
|
8
17
|
lanesPane,
|
|
9
|
-
loopSection,
|
|
10
18
|
queueSection,
|
|
11
19
|
shownState,
|
|
12
20
|
tokens,
|
|
@@ -15,9 +23,12 @@ import {
|
|
|
15
23
|
|
|
16
24
|
export const TABS = [
|
|
17
25
|
{ id: 'lanes', label: 'Lanes', hotkey: '1' },
|
|
18
|
-
{ id: '
|
|
19
|
-
{ id: '
|
|
20
|
-
{ id: '
|
|
26
|
+
{ id: 'decisions', label: 'Decisions', hotkey: '2' },
|
|
27
|
+
{ id: 'journal', label: 'Journal', hotkey: '3' },
|
|
28
|
+
{ id: 'code', label: 'Code map', hotkey: '4' },
|
|
29
|
+
{ id: 'map', label: 'Trail', hotkey: '5' },
|
|
30
|
+
{ id: 'queue', label: 'Queue', hotkey: '6' },
|
|
31
|
+
{ id: 'usage', label: 'Usage', hotkey: '7' },
|
|
21
32
|
];
|
|
22
33
|
|
|
23
34
|
/** The pane's own state, kept by the mod between draws. */
|
|
@@ -173,6 +184,8 @@ export function lanesFrame(view, els, on) {
|
|
|
173
184
|
children: [
|
|
174
185
|
text('!', { color: 'yellow', bold: true }),
|
|
175
186
|
text(line.text, { bold: true }),
|
|
187
|
+
// DECIDE-2: which story it's for.
|
|
188
|
+
...(line.context ? [text(line.context, { dimColor: true, wrap: 'truncate-end' })] : []),
|
|
176
189
|
...actions.map((a) => {
|
|
177
190
|
const letter = LETTER[a.id] && !used.has(LETTER[a.id]) ? LETTER[a.id] : undefined;
|
|
178
191
|
if (letter) used.add(letter);
|
|
@@ -254,35 +267,48 @@ export function lanesFrame(view, els, on) {
|
|
|
254
267
|
|
|
255
268
|
const selected = state.lanes.find((l) => l.name === ui.selected) ?? null;
|
|
256
269
|
const body =
|
|
257
|
-
ui.tab === '
|
|
258
|
-
?
|
|
259
|
-
: ui.tab === '
|
|
260
|
-
?
|
|
261
|
-
: ui.tab === '
|
|
262
|
-
?
|
|
263
|
-
:
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
270
|
+
ui.tab === 'decisions'
|
|
271
|
+
? decisionsTab(view.cards ?? [], ui, els, on)
|
|
272
|
+
: ui.tab === 'journal'
|
|
273
|
+
? journalTab(view.journal ?? [], els, view.now)
|
|
274
|
+
: ui.tab === 'code'
|
|
275
|
+
? codeTab(view, els, on)
|
|
276
|
+
: ui.tab === 'map'
|
|
277
|
+
? mapTab(view.status, ui.cell, els, on, view.trail, {
|
|
278
|
+
surface: view.surface,
|
|
279
|
+
columns: view.columns,
|
|
280
|
+
})
|
|
281
|
+
: ui.tab === 'queue'
|
|
282
|
+
? queueSection(loop, text, Box)
|
|
283
|
+
: ui.tab === 'usage'
|
|
284
|
+
? usageSection(view.usage, plan, text, Box)
|
|
285
|
+
: [
|
|
286
|
+
lanesPane(state, els, view.usage, plan, loop, {
|
|
287
|
+
filter: ui.filter,
|
|
288
|
+
search: ui.search,
|
|
289
|
+
selected: ui.selected,
|
|
290
|
+
live: view.live ?? {},
|
|
291
|
+
art: view.art ?? null,
|
|
292
|
+
detail: selected
|
|
293
|
+
? {
|
|
294
|
+
agent: selected.agent ?? null,
|
|
295
|
+
owns: view.paths?.[selected.name] ?? [],
|
|
296
|
+
stories: laneStories(selected, state),
|
|
297
|
+
}
|
|
298
|
+
: null,
|
|
299
|
+
combined: view.combined ?? null,
|
|
300
|
+
}),
|
|
301
|
+
];
|
|
280
302
|
const empty =
|
|
281
|
-
ui.tab === '
|
|
282
|
-
? 'The
|
|
283
|
-
: ui.tab === '
|
|
284
|
-
? '
|
|
285
|
-
:
|
|
303
|
+
ui.tab === 'decisions'
|
|
304
|
+
? 'Nothing needs you. The lead is handling the build.'
|
|
305
|
+
: ui.tab === 'journal'
|
|
306
|
+
? 'Nothing recorded yet: the journal starts with the brief.'
|
|
307
|
+
: ui.tab === 'queue'
|
|
308
|
+
? 'The merge queue is empty.'
|
|
309
|
+
: ui.tab === 'usage'
|
|
310
|
+
? 'No usage measured yet this session.'
|
|
311
|
+
: null;
|
|
286
312
|
|
|
287
313
|
// VIEW-6: j/k move between lanes; the selected lane's acts; f filters.
|
|
288
314
|
const opening = selected ? storyToOpen(selected, state) : null;
|
|
@@ -819,7 +845,14 @@ export function moveCell(requirements, id, step, { rows = false } = {}) {
|
|
|
819
845
|
* The Map tab (TRACE-1 to TRACE-4): a row per spec, a cell per requirement, a legend, and the
|
|
820
846
|
* selected requirement's stories and tests. `status` is `code-kit status --json`'s.
|
|
821
847
|
*/
|
|
822
|
-
export function mapTab(
|
|
848
|
+
export function mapTab(
|
|
849
|
+
status,
|
|
850
|
+
selectedId,
|
|
851
|
+
{ Box, Text, Button, Select, Svg },
|
|
852
|
+
on,
|
|
853
|
+
trail = null,
|
|
854
|
+
{ surface = 'terminal', columns = 100 } = {},
|
|
855
|
+
) {
|
|
823
856
|
const text = (value, style = {}) => Text({ ...style, children: [value] });
|
|
824
857
|
const requirements = status?.requirements ?? [];
|
|
825
858
|
if (!requirements.length)
|
|
@@ -929,6 +962,28 @@ export function mapTab(status, selectedId, { Box, Text, Button, Select }, on) {
|
|
|
929
962
|
),
|
|
930
963
|
)
|
|
931
964
|
: [text('No story delivers it yet: a gap in the plan.', { color: 'red' })]),
|
|
965
|
+
// TRAIL-1: the code its stories changed, once the trail is read.
|
|
966
|
+
...(() => {
|
|
967
|
+
const row = trail?.requirements?.find((r) => r.id === selected.id);
|
|
968
|
+
if (!row) return [];
|
|
969
|
+
return row.files.length
|
|
970
|
+
? [
|
|
971
|
+
text(`Code its stories changed (${row.files.length})`, { bold: true }),
|
|
972
|
+
Box({
|
|
973
|
+
key: 'map-files',
|
|
974
|
+
children: [
|
|
975
|
+
text(
|
|
976
|
+
` ${row.files
|
|
977
|
+
.slice(0, 6)
|
|
978
|
+
.map((f) => f.split('/').pop())
|
|
979
|
+
.join(', ')}${row.files.length > 6 ? ', …' : ''}`,
|
|
980
|
+
{ dimColor: true, wrap: 'truncate-end' },
|
|
981
|
+
),
|
|
982
|
+
],
|
|
983
|
+
}),
|
|
984
|
+
]
|
|
985
|
+
: [text('No code changed for it yet.', { dimColor: true })];
|
|
986
|
+
})(),
|
|
932
987
|
...(selected.tests?.length
|
|
933
988
|
? [
|
|
934
989
|
text('Tests that name it', { bold: true }),
|
|
@@ -939,5 +994,385 @@ export function mapTab(status, selectedId, { Box, Text, Button, Select }, on) {
|
|
|
939
994
|
: [text('No test names it.', { dimColor: true })]),
|
|
940
995
|
],
|
|
941
996
|
});
|
|
942
|
-
|
|
997
|
+
// TRAIL-1: the selected requirement's spec as a trail: each requirement to its stories, the code
|
|
998
|
+
// they changed and the tests that name it, a gap where any link is missing.
|
|
999
|
+
const spec = selected.file;
|
|
1000
|
+
const trailRows = (trail?.requirements ?? []).filter(
|
|
1001
|
+
(r) =>
|
|
1002
|
+
r.spec === spec?.split('/').pop() && requirements.find((q) => q.id === r.id)?.file === spec,
|
|
1003
|
+
);
|
|
1004
|
+
const TONE = {
|
|
1005
|
+
req: {},
|
|
1006
|
+
selected: { bold: true, color: 'cyan' },
|
|
1007
|
+
dim: { dimColor: true },
|
|
1008
|
+
done: { color: 'green' },
|
|
1009
|
+
moving: { color: 'blue' },
|
|
1010
|
+
waiting: { color: 'red' },
|
|
1011
|
+
gap: { color: 'red' },
|
|
1012
|
+
test: { color: 'green' },
|
|
1013
|
+
};
|
|
1014
|
+
const flow =
|
|
1015
|
+
surface && surface !== 'terminal' && Svg
|
|
1016
|
+
? Svg({
|
|
1017
|
+
source: trailSvg(trailRows, { selected: selected.id }),
|
|
1018
|
+
alt: `${label(spec)}'s trail: each requirement to its stories, code and tests`,
|
|
1019
|
+
})
|
|
1020
|
+
: Box({
|
|
1021
|
+
key: 'trail-flow',
|
|
1022
|
+
flexDirection: 'column',
|
|
1023
|
+
children: trailRowsDrawn(trailRows, { selected: selected.id, width: columns }).map(
|
|
1024
|
+
(runs, i) =>
|
|
1025
|
+
Box({
|
|
1026
|
+
key: `trail-${trailRows[i].id}`,
|
|
1027
|
+
flexDirection: 'row',
|
|
1028
|
+
children: runs.map((r) => text(r.text, TONE[r.style] ?? {})),
|
|
1029
|
+
}),
|
|
1030
|
+
),
|
|
1031
|
+
});
|
|
1032
|
+
const trailBox = trailRows.length
|
|
1033
|
+
? [
|
|
1034
|
+
Box({
|
|
1035
|
+
key: 'trail',
|
|
1036
|
+
flexDirection: 'column',
|
|
1037
|
+
children: [text(`Trail · ${label(spec)}`, { bold: true }), flow],
|
|
1038
|
+
}),
|
|
1039
|
+
]
|
|
1040
|
+
: [];
|
|
1041
|
+
return [legend, grid, keys, ...trailBox, detail];
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
/** A tint darkened toward the terminal's ground, for a glowing box's fill: `share` of the tint kept. */
|
|
1045
|
+
const shade = (hex, share) => {
|
|
1046
|
+
const n = Number.parseInt(String(hex).replace('#', ''), 16);
|
|
1047
|
+
if (!Number.isFinite(n)) return undefined;
|
|
1048
|
+
const c = (v) => Math.round(((n >> v) & 0xff) * share + 14 * (1 - share));
|
|
1049
|
+
return `#${[16, 8, 0].map((v) => c(v).toString(16).padStart(2, '0')).join('')}`;
|
|
1050
|
+
};
|
|
1051
|
+
|
|
1052
|
+
/** A code map run's text style: its lane's tint where it glows, a fill inside a glowing box. */
|
|
1053
|
+
function runStyle(r, tints) {
|
|
1054
|
+
const tint = r.lane ? (tints[r.lane] ?? '#5fb3b3') : null;
|
|
1055
|
+
if (tint && (r.style === 'fill-read' || r.style === 'fill-edit'))
|
|
1056
|
+
return {
|
|
1057
|
+
backgroundColor: shade(tint, r.style === 'fill-edit' ? 0.45 : 0.28),
|
|
1058
|
+
color: 'white',
|
|
1059
|
+
...(r.style === 'fill-edit' ? { bold: true } : {}),
|
|
1060
|
+
};
|
|
1061
|
+
if (tint && (r.style === 'reading' || r.style === 'editing'))
|
|
1062
|
+
return { color: tint, bold: r.style === 'editing' };
|
|
1063
|
+
return RUN_STYLE[r.style] ?? {};
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
/** The colour each style of the code map's runs takes, the lane's tint where it glows. */
|
|
1067
|
+
const RUN_STYLE = {
|
|
1068
|
+
dim: { dimColor: true },
|
|
1069
|
+
node: {},
|
|
1070
|
+
selected: { color: 'cyan', bold: true },
|
|
1071
|
+
ok: { color: 'green' },
|
|
1072
|
+
warn: { color: 'yellow' },
|
|
1073
|
+
};
|
|
1074
|
+
|
|
1075
|
+
/**
|
|
1076
|
+
* MAP-1 to MAP-4: the Code map tab: the stories to choose from, the story's files by layer with their
|
|
1077
|
+
* imports (glowing where a lane reads or edits them), and the selected file's card and neighbours.
|
|
1078
|
+
* `view` holds { state, codeMap, ui, glow, art, surface, columns }; `on` holds onMapStory(id),
|
|
1079
|
+
* onMapFile(path), onOpenFile(path).
|
|
1080
|
+
*/
|
|
1081
|
+
export function codeTab(view, { Box, Text, Button, Svg }, on) {
|
|
1082
|
+
const text = (value, style = {}) => Text({ ...style, children: [value] });
|
|
1083
|
+
const { state, ui = DEFAULT_UI, codeMap: map = null, glow = {}, art = null } = view;
|
|
1084
|
+
const storyId = mapStoryOf(state, ui.selected, ui.mapStory);
|
|
1085
|
+
const choices = (state?.stories ?? []).filter(
|
|
1086
|
+
(s) => s.id === storyId || ['in progress', 'review', 'sent back'].includes(s.state),
|
|
1087
|
+
);
|
|
1088
|
+
const picker = Box({
|
|
1089
|
+
key: 'map-stories',
|
|
1090
|
+
flexDirection: 'row',
|
|
1091
|
+
columnGap: 1,
|
|
1092
|
+
flexWrap: 'wrap',
|
|
1093
|
+
children: choices.map((s) =>
|
|
1094
|
+
Button({
|
|
1095
|
+
key: `map-story-${s.id}`,
|
|
1096
|
+
label: `${s.id} ${s.title}`,
|
|
1097
|
+
...(s.id === storyId ? { variant: 'primary' } : { plain: true }),
|
|
1098
|
+
onPress: () => on.onMapStory(s.id),
|
|
1099
|
+
}),
|
|
1100
|
+
),
|
|
1101
|
+
});
|
|
1102
|
+
if (!storyId) return [text('No story to map yet: the plan has none open.', { dimColor: true })];
|
|
1103
|
+
if (!map || map.story?.id !== storyId)
|
|
1104
|
+
return [picker, text(`Reading ${storyId}'s code…`, { dimColor: true })];
|
|
1105
|
+
if (!map.files.length)
|
|
1106
|
+
return [picker, text(`${storyId} hasn't changed any code yet.`, { dimColor: true })];
|
|
1107
|
+
const selected = map.files.some((f) => f.path === ui.mapFile) ? ui.mapFile : map.files[0].path;
|
|
1108
|
+
// The lanes' tints are RGB numbers (the characters' colours); text and SVG take them as hex.
|
|
1109
|
+
const tints = Object.fromEntries(
|
|
1110
|
+
Object.entries(art?.tints ?? {}).map(([lane, rgb]) => [
|
|
1111
|
+
lane,
|
|
1112
|
+
typeof rgb === 'number' ? `#${rgb.toString(16).padStart(6, '0')}` : rgb,
|
|
1113
|
+
]),
|
|
1114
|
+
);
|
|
1115
|
+
const width = Math.max(40, (view.columns ?? 100) - 4);
|
|
1116
|
+
const drawing =
|
|
1117
|
+
view.surface !== 'terminal' && Svg
|
|
1118
|
+
? Svg({
|
|
1119
|
+
source: mapSvg(map, { glow, selected, tints, width }),
|
|
1120
|
+
alt: `${storyId}'s code: ${map.files.length} files by layer`,
|
|
1121
|
+
isInteractive: true,
|
|
1122
|
+
})
|
|
1123
|
+
: Box({
|
|
1124
|
+
key: 'code-map',
|
|
1125
|
+
flexDirection: 'column',
|
|
1126
|
+
children: codeRows(map, { glow, selected, width }).map((runs, y) =>
|
|
1127
|
+
Box({
|
|
1128
|
+
key: `code-row-${y}`,
|
|
1129
|
+
flexDirection: 'row',
|
|
1130
|
+
children: runs.map((r) => text(r.text, runStyle(r, tints))),
|
|
1131
|
+
}),
|
|
1132
|
+
),
|
|
1133
|
+
});
|
|
1134
|
+
const at = map.files.findIndex((f) => f.path === selected);
|
|
1135
|
+
const file = map.files[at];
|
|
1136
|
+
const card = cardOf(file);
|
|
1137
|
+
const near = neighboursOf(map, selected);
|
|
1138
|
+
const short = (p) => p.split('/').pop();
|
|
1139
|
+
const detail = Box({
|
|
1140
|
+
key: 'code-file',
|
|
1141
|
+
flexDirection: 'column',
|
|
1142
|
+
borderStyle: 'round',
|
|
1143
|
+
borderColor: 'gray',
|
|
1144
|
+
paddingX: 1,
|
|
1145
|
+
children: [
|
|
1146
|
+
text(selected, { bold: true }),
|
|
1147
|
+
text(`${file.layer}${file.lane ? ` · ${file.lane} lane` : ''}`, { dimColor: true }),
|
|
1148
|
+
...(card
|
|
1149
|
+
? [
|
|
1150
|
+
text(
|
|
1151
|
+
card.state === 'missing'
|
|
1152
|
+
? 'No why written yet: Context Graph keeps one as a card once it is.'
|
|
1153
|
+
: card.text,
|
|
1154
|
+
card.state === 'current' ? {} : { color: 'yellow' },
|
|
1155
|
+
),
|
|
1156
|
+
...(card.state === 'stale'
|
|
1157
|
+
? [text('The file changed since its why was written.', { dimColor: true })]
|
|
1158
|
+
: []),
|
|
1159
|
+
]
|
|
1160
|
+
: []),
|
|
1161
|
+
...(near.imports.length
|
|
1162
|
+
? [text(`imports ${near.imports.map(short).join(', ')}`, { dimColor: true })]
|
|
1163
|
+
: []),
|
|
1164
|
+
...(near.importers.length
|
|
1165
|
+
? [text(`imported by ${near.importers.map(short).join(', ')}`, { dimColor: true })]
|
|
1166
|
+
: []),
|
|
1167
|
+
],
|
|
1168
|
+
});
|
|
1169
|
+
const keys = Box({
|
|
1170
|
+
key: 'code-keys',
|
|
1171
|
+
flexDirection: 'row',
|
|
1172
|
+
columnGap: 1,
|
|
1173
|
+
children: [
|
|
1174
|
+
Button({
|
|
1175
|
+
key: 'code-next',
|
|
1176
|
+
label: 'Next file',
|
|
1177
|
+
hotkey: 'j',
|
|
1178
|
+
plain: true,
|
|
1179
|
+
onPress: () => on.onMapFile(map.files[(at + 1) % map.files.length].path),
|
|
1180
|
+
}),
|
|
1181
|
+
Button({
|
|
1182
|
+
key: 'code-prev',
|
|
1183
|
+
label: 'Previous',
|
|
1184
|
+
hotkey: 'k',
|
|
1185
|
+
plain: true,
|
|
1186
|
+
onPress: () => on.onMapFile(map.files[(at - 1 + map.files.length) % map.files.length].path),
|
|
1187
|
+
}),
|
|
1188
|
+
...(map.open
|
|
1189
|
+
? [
|
|
1190
|
+
Button({
|
|
1191
|
+
key: 'code-open',
|
|
1192
|
+
label: 'Open in Context Graph',
|
|
1193
|
+
hotkey: 'o',
|
|
1194
|
+
plain: true,
|
|
1195
|
+
onPress: () => on.onOpenFile(selected),
|
|
1196
|
+
}),
|
|
1197
|
+
]
|
|
1198
|
+
: []),
|
|
1199
|
+
],
|
|
1200
|
+
});
|
|
1201
|
+
const legend = Box({
|
|
1202
|
+
key: 'code-legend',
|
|
1203
|
+
flexDirection: 'row',
|
|
1204
|
+
columnGap: 2,
|
|
1205
|
+
flexWrap: 'wrap',
|
|
1206
|
+
children: MAP_LEGEND.map((l) =>
|
|
1207
|
+
text(
|
|
1208
|
+
l.text,
|
|
1209
|
+
l.style === 'glow' ? { color: tints[file.lane] ?? 'cyan' } : (RUN_STYLE[l.style] ?? {}),
|
|
1210
|
+
),
|
|
1211
|
+
),
|
|
1212
|
+
});
|
|
1213
|
+
return [picker, drawing, legend, detail, keys];
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
/**
|
|
1217
|
+
* DECIDE-3: the Decisions tab: each decision that needs the person, the selected one as a card with its
|
|
1218
|
+
* lineage (what they asked for, the story and requirements, what was happening, what's settled, why it
|
|
1219
|
+
* needs them) and its options, each saying what it does. `on`: onDecision(i), onNeed(id, line).
|
|
1220
|
+
*/
|
|
1221
|
+
export function decisionsTab(cards, ui, { Box, Text, Button }, on) {
|
|
1222
|
+
const text = (value, style = {}) => Text({ ...style, children: [value] });
|
|
1223
|
+
if (!cards.length) return [];
|
|
1224
|
+
const at = Math.min(ui.decision ?? 0, cards.length - 1);
|
|
1225
|
+
const card = cards[at];
|
|
1226
|
+
const row = (label, value, style = {}) =>
|
|
1227
|
+
Box({
|
|
1228
|
+
key: `decision-${label}`,
|
|
1229
|
+
flexDirection: 'row',
|
|
1230
|
+
columnGap: 1,
|
|
1231
|
+
children: [
|
|
1232
|
+
text(label.padEnd(15), { dimColor: true }),
|
|
1233
|
+
text(value, { wrap: 'wrap', ...style }),
|
|
1234
|
+
],
|
|
1235
|
+
});
|
|
1236
|
+
const list = Box({
|
|
1237
|
+
key: 'decision-list',
|
|
1238
|
+
flexDirection: 'column',
|
|
1239
|
+
children: cards.map((c, i) =>
|
|
1240
|
+
Button({
|
|
1241
|
+
key: `decision-pick-${i}`,
|
|
1242
|
+
label: `${i === at ? '›' : ' '} ${c.title}`,
|
|
1243
|
+
plain: true,
|
|
1244
|
+
onPress: () => on.onDecision(i),
|
|
1245
|
+
}),
|
|
1246
|
+
),
|
|
1247
|
+
});
|
|
1248
|
+
const detail = Box({
|
|
1249
|
+
key: 'decision-card',
|
|
1250
|
+
flexDirection: 'column',
|
|
1251
|
+
borderStyle: 'round',
|
|
1252
|
+
borderColor: 'yellow',
|
|
1253
|
+
paddingX: 1,
|
|
1254
|
+
rowGap: 0,
|
|
1255
|
+
children: [
|
|
1256
|
+
text(card.title, { bold: true }),
|
|
1257
|
+
...(card.asked ? [row('You asked for', card.asked)] : []),
|
|
1258
|
+
...(card.story
|
|
1259
|
+
? [
|
|
1260
|
+
row(
|
|
1261
|
+
'Story',
|
|
1262
|
+
`${card.story.id} ${card.story.title}${card.story.branch ? ` · ${card.story.branch}` : ''}`,
|
|
1263
|
+
),
|
|
1264
|
+
]
|
|
1265
|
+
: []),
|
|
1266
|
+
...(card.requirements.length
|
|
1267
|
+
? [
|
|
1268
|
+
row(
|
|
1269
|
+
'Requirements',
|
|
1270
|
+
card.requirements.map((r) => `${r.id}${r.title ? ` ${r.title}` : ''}`).join('; '),
|
|
1271
|
+
),
|
|
1272
|
+
]
|
|
1273
|
+
: []),
|
|
1274
|
+
...(card.doing ? [row('Happening', card.doing)] : []),
|
|
1275
|
+
...(card.settled.length
|
|
1276
|
+
? [
|
|
1277
|
+
text('Already settled', { dimColor: true }),
|
|
1278
|
+
...card.settled.map((line, i) =>
|
|
1279
|
+
Box({
|
|
1280
|
+
key: `decision-settled-${i}`,
|
|
1281
|
+
children: [text(` · ${line}`, { wrap: 'wrap' })],
|
|
1282
|
+
}),
|
|
1283
|
+
),
|
|
1284
|
+
]
|
|
1285
|
+
: []),
|
|
1286
|
+
...(card.why ? [row('Why it is yours', card.why, { color: 'yellow' })] : []),
|
|
1287
|
+
text('Your options', { dimColor: true }),
|
|
1288
|
+
...card.options.map((o) =>
|
|
1289
|
+
Box({
|
|
1290
|
+
key: `decision-option-${o.id}`,
|
|
1291
|
+
flexDirection: 'row',
|
|
1292
|
+
columnGap: 2,
|
|
1293
|
+
children: [
|
|
1294
|
+
Button({
|
|
1295
|
+
key: `decide-${o.id}`,
|
|
1296
|
+
label: o.label,
|
|
1297
|
+
...(o.hotkey ? { hotkey: o.hotkey } : {}),
|
|
1298
|
+
variant: 'primary',
|
|
1299
|
+
onPress: () => on.onNeed(o.id, card.line),
|
|
1300
|
+
}),
|
|
1301
|
+
...(o.effect ? [text(o.effect, { dimColor: true, wrap: 'wrap' })] : []),
|
|
1302
|
+
],
|
|
1303
|
+
}),
|
|
1304
|
+
),
|
|
1305
|
+
],
|
|
1306
|
+
});
|
|
1307
|
+
return [
|
|
1308
|
+
text(
|
|
1309
|
+
`${cards.length} decision${cards.length === 1 ? '' : 's'} need${cards.length === 1 ? 's' : ''} you. Everything else is the lead's.`,
|
|
1310
|
+
{ bold: true },
|
|
1311
|
+
),
|
|
1312
|
+
...(cards.length > 1 ? [list] : []),
|
|
1313
|
+
detail,
|
|
1314
|
+
];
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
const JOURNAL_GLYPH = {
|
|
1318
|
+
request: '✦',
|
|
1319
|
+
decision: '◆',
|
|
1320
|
+
spec: '▤',
|
|
1321
|
+
setup: '⚙',
|
|
1322
|
+
dispatch: '→',
|
|
1323
|
+
sentBack: '↩',
|
|
1324
|
+
merge: '✓',
|
|
1325
|
+
approval: '✔',
|
|
1326
|
+
milestone: '★',
|
|
1327
|
+
loop: '·',
|
|
1328
|
+
};
|
|
1329
|
+
|
|
1330
|
+
/**
|
|
1331
|
+
* JOURNAL-1: the Journal tab: the build's lineage, newest last, each event with its time and a glyph,
|
|
1332
|
+
* your own approvals and choices marked. The latest 60 are drawn.
|
|
1333
|
+
*/
|
|
1334
|
+
export function journalTab(events, { Box, Text }, now = Date.now()) {
|
|
1335
|
+
const text = (value, style = {}) => Text({ ...style, children: [value] });
|
|
1336
|
+
if (!events.length) return [];
|
|
1337
|
+
const shown = events.slice(-60);
|
|
1338
|
+
const day = (at) => new Date(at).toISOString().slice(0, 10);
|
|
1339
|
+
const time = (at) => new Date(at).toISOString().slice(11, 16);
|
|
1340
|
+
const rows = [];
|
|
1341
|
+
let lastDay = null;
|
|
1342
|
+
shown.forEach((e, i) => {
|
|
1343
|
+
if (day(e.at) !== lastDay) {
|
|
1344
|
+
lastDay = day(e.at);
|
|
1345
|
+
rows.push(
|
|
1346
|
+
Box({ key: `journal-day-${i}`, children: [text(lastDay, { bold: true, dimColor: true })] }),
|
|
1347
|
+
);
|
|
1348
|
+
}
|
|
1349
|
+
const yours = e.who === 'person' || e.kind === 'request';
|
|
1350
|
+
rows.push(
|
|
1351
|
+
Box({
|
|
1352
|
+
key: `journal-${i}`,
|
|
1353
|
+
flexDirection: 'row',
|
|
1354
|
+
columnGap: 1,
|
|
1355
|
+
children: [
|
|
1356
|
+
text(time(e.at), { dimColor: true }),
|
|
1357
|
+
text(JOURNAL_GLYPH[e.kind] ?? '·', {
|
|
1358
|
+
color: yours
|
|
1359
|
+
? 'yellow'
|
|
1360
|
+
: e.kind === 'merge' || e.kind === 'milestone'
|
|
1361
|
+
? 'green'
|
|
1362
|
+
: e.kind === 'sentBack'
|
|
1363
|
+
? 'magenta'
|
|
1364
|
+
: undefined,
|
|
1365
|
+
}),
|
|
1366
|
+
text(e.text, { wrap: 'wrap', ...(yours ? { color: 'yellow' } : {}) }),
|
|
1367
|
+
],
|
|
1368
|
+
}),
|
|
1369
|
+
);
|
|
1370
|
+
});
|
|
1371
|
+
return [
|
|
1372
|
+
text(
|
|
1373
|
+
`From your request to now: ${events.length} event${events.length === 1 ? '' : 's'}${events.length > shown.length ? `, the latest ${shown.length} shown` : ''}. Yours are in yellow.`,
|
|
1374
|
+
{ dimColor: true },
|
|
1375
|
+
),
|
|
1376
|
+
Box({ key: 'journal', flexDirection: 'column', children: rows }),
|
|
1377
|
+
];
|
|
943
1378
|
}
|