@quolu/lattice 0.12.10 → 0.12.12
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/package.json +2 -2
- package/src/bridge-address.mjs +107 -0
- package/src/bridge-cli.mjs +78 -8
- package/src/bridge-launch-agent.mjs +12 -0
- package/src/bridge-registrar.mjs +102 -0
- package/src/bridge-server.mjs +53 -5
- package/src/cli-help.mjs +8 -3
- package/src/todo-cli.mjs +39 -12
- package/src/todo-gantt-html.mjs +42 -13
- package/src/todo-gantt-layout.mjs +68 -14
- package/src/todo-gantt-scope.mjs +296 -0
- package/src/todo-gantt-svg.mjs +17 -6
- package/src/todo-store.mjs +6 -5
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Todo Gantt scope projection.
|
|
3
|
+
*
|
|
4
|
+
* The store carries every completed ToDo forward across plan revisions on
|
|
5
|
+
* purpose — an accepted artifact is the predecessor of the work that follows
|
|
6
|
+
* it. That makes the dependency diagram grow monotonically: once a campaign
|
|
7
|
+
* finishes, its whole tree stays on screen forever and every later revision
|
|
8
|
+
* stacks on top of it. The journal is the source of truth, so nothing is
|
|
9
|
+
* removed from the store; the diagram is a projection, and this module is
|
|
10
|
+
* where that projection narrows.
|
|
11
|
+
*
|
|
12
|
+
* The rule is "fold the dead branches": a completed ToDo that no longer leads
|
|
13
|
+
* to any live work is history, and history collapses into one labelled node
|
|
14
|
+
* per finished branch. Completed ToDos that are still the direct premise of
|
|
15
|
+
* live work stay visible, because they are the context for what is dispatchable
|
|
16
|
+
* right now.
|
|
17
|
+
*
|
|
18
|
+
* This module is pure graph math over the layout's internal node/edge shape.
|
|
19
|
+
* It must run AFTER dependency waves, the longest dependency chain and the
|
|
20
|
+
* ready frontier have been computed on the FULL graph — those numbers describe
|
|
21
|
+
* the real plan, and measuring them on a folded graph would make them lie.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
export const TODO_GANTT_SCOPES = Object.freeze(['live', 'all']);
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Hops of completed predecessors kept in front of live work. 1 = keep the
|
|
28
|
+
* direct premises of live ToDos, fold everything upstream of them.
|
|
29
|
+
*/
|
|
30
|
+
export const DEFAULT_FOLD_DISTANCE = 1;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* `task_id` values in the store match /^[0-9A-Za-z][0-9A-Za-z._-]{0,127}$/, so
|
|
34
|
+
* a leading '~' cannot collide with a real ToDo.
|
|
35
|
+
*/
|
|
36
|
+
const FOLD_TASK_ID_PREFIX = '~folded';
|
|
37
|
+
|
|
38
|
+
export class TodoGanttScopeError extends Error {
|
|
39
|
+
constructor(code, message, detail = null) {
|
|
40
|
+
super(message);
|
|
41
|
+
this.name = 'TodoGanttScopeError';
|
|
42
|
+
this.code = code;
|
|
43
|
+
this.detail = detail;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function compareText(left, right) {
|
|
48
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function isFoldNodeRef(ref) {
|
|
52
|
+
return typeof ref?.task_id === 'string' && ref.task_id.startsWith(`${FOLD_TASK_ID_PREFIX}:`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Forward distance from each node to the nearest live (non-done) node, over the
|
|
57
|
+
* dependency DAG. A live node is at distance 0; a node with no live descendant
|
|
58
|
+
* is at Infinity.
|
|
59
|
+
*
|
|
60
|
+
* Edges always increase the wave (`assignWaves` is a longest-path layering), so
|
|
61
|
+
* visiting nodes in descending wave order guarantees every successor is settled
|
|
62
|
+
* before the node that depends on it.
|
|
63
|
+
*/
|
|
64
|
+
function distanceToLive(nodes, edges, wave) {
|
|
65
|
+
const outgoing = new Map(nodes.map(({ key }) => [key, []]));
|
|
66
|
+
for (const edge of edges) outgoing.get(edge.from).push(edge.to);
|
|
67
|
+
const distance = new Map();
|
|
68
|
+
const ordered = [...nodes].sort((left, right) => wave.get(right.key) - wave.get(left.key)
|
|
69
|
+
|| compareText(right.key, left.key));
|
|
70
|
+
for (const node of ordered) {
|
|
71
|
+
if (node.status !== 'done') {
|
|
72
|
+
distance.set(node.key, 0);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
let best = Infinity;
|
|
76
|
+
for (const successor of outgoing.get(node.key)) {
|
|
77
|
+
const settled = distance.get(successor);
|
|
78
|
+
if (settled !== undefined && settled + 1 < best) best = settled + 1;
|
|
79
|
+
}
|
|
80
|
+
distance.set(node.key, best);
|
|
81
|
+
}
|
|
82
|
+
return distance;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function foldableKeys(nodes, distance, foldDistance) {
|
|
86
|
+
return new Set(nodes
|
|
87
|
+
.filter((node) => node.status === 'done' && distance.get(node.key) > foldDistance)
|
|
88
|
+
.map(({ key }) => key));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Group foldable nodes into fold units.
|
|
93
|
+
*
|
|
94
|
+
* `byComponent` groups each weakly connected component of the foldable subgraph
|
|
95
|
+
* into one unit — the compact projection, one node per finished branch.
|
|
96
|
+
* `byComponentAndWave` additionally splits each component per dependency wave.
|
|
97
|
+
* The latter is always acyclic once contracted (every dependency edge strictly
|
|
98
|
+
* increases the wave, so contracted edges do too), which makes it the
|
|
99
|
+
* guaranteed-safe refinement when contraction would otherwise close a cycle.
|
|
100
|
+
*/
|
|
101
|
+
function groupFoldable(nodes, edges, foldable, wave, splitByWave) {
|
|
102
|
+
const parent = new Map([...foldable].map((key) => [key, key]));
|
|
103
|
+
const find = (key) => {
|
|
104
|
+
let root = key;
|
|
105
|
+
while (parent.get(root) !== root) root = parent.get(root);
|
|
106
|
+
let cursor = key;
|
|
107
|
+
while (parent.get(cursor) !== root) {
|
|
108
|
+
const next = parent.get(cursor);
|
|
109
|
+
parent.set(cursor, root);
|
|
110
|
+
cursor = next;
|
|
111
|
+
}
|
|
112
|
+
return root;
|
|
113
|
+
};
|
|
114
|
+
const union = (left, right) => {
|
|
115
|
+
const leftRoot = find(left);
|
|
116
|
+
const rightRoot = find(right);
|
|
117
|
+
if (leftRoot === rightRoot) return;
|
|
118
|
+
if (compareText(leftRoot, rightRoot) <= 0) parent.set(rightRoot, leftRoot);
|
|
119
|
+
else parent.set(leftRoot, rightRoot);
|
|
120
|
+
};
|
|
121
|
+
const nodeByKey = new Map(nodes.map((node) => [node.key, node]));
|
|
122
|
+
for (const edge of edges) {
|
|
123
|
+
if (!foldable.has(edge.from) || !foldable.has(edge.to)) continue;
|
|
124
|
+
// A fold unit never spans plans: the diagram groups by plan and a unit that
|
|
125
|
+
// straddled two plans would have no honest lane to sit in.
|
|
126
|
+
if (nodeByKey.get(edge.from).ref.plan_key !== nodeByKey.get(edge.to).ref.plan_key) continue;
|
|
127
|
+
union(edge.from, edge.to);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const unitByNode = new Map();
|
|
131
|
+
const members = new Map();
|
|
132
|
+
for (const key of [...foldable].sort(compareText)) {
|
|
133
|
+
const unitKey = splitByWave
|
|
134
|
+
? JSON.stringify([find(key), wave.get(key)])
|
|
135
|
+
: JSON.stringify([find(key), null]);
|
|
136
|
+
unitByNode.set(key, unitKey);
|
|
137
|
+
if (!members.has(unitKey)) members.set(unitKey, []);
|
|
138
|
+
members.get(unitKey).push(key);
|
|
139
|
+
}
|
|
140
|
+
return { unitByNode, members };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function dominantLane(memberNodes) {
|
|
144
|
+
const counts = new Map();
|
|
145
|
+
for (const node of memberNodes) counts.set(node.lane, (counts.get(node.lane) ?? 0) + 1);
|
|
146
|
+
return [...counts.entries()]
|
|
147
|
+
.sort(([leftLane, leftCount], [rightLane, rightCount]) => rightCount - leftCount
|
|
148
|
+
|| compareText(leftLane, rightLane))[0][0];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function buildSummaryNodes(nodes, members, longestChainKeys) {
|
|
152
|
+
const nodeByKey = new Map(nodes.map((node) => [node.key, node]));
|
|
153
|
+
// Order fold units by their first member so synthetic ids are stable across runs.
|
|
154
|
+
const ordered = [...members.entries()]
|
|
155
|
+
.map(([unitKey, memberKeys]) => ({ unitKey, memberKeys: [...memberKeys].sort(compareText) }))
|
|
156
|
+
.sort((left, right) => compareText(left.memberKeys[0], right.memberKeys[0]));
|
|
157
|
+
|
|
158
|
+
const summaryByUnit = new Map();
|
|
159
|
+
ordered.forEach((unit, index) => {
|
|
160
|
+
const memberNodes = unit.memberKeys.map((key) => nodeByKey.get(key));
|
|
161
|
+
const { plan_key: planKey, project_id: projectId } = memberNodes[0].ref;
|
|
162
|
+
const ref = { project_id: projectId, plan_key: planKey, task_id: `${FOLD_TASK_ID_PREFIX}:${index}` };
|
|
163
|
+
const key = JSON.stringify([ref.project_id, ref.plan_key, ref.task_id]);
|
|
164
|
+
const lanes = [...new Set(memberNodes.map((node) => node.lane))].sort(compareText);
|
|
165
|
+
summaryByUnit.set(unit.unitKey, {
|
|
166
|
+
key,
|
|
167
|
+
ref,
|
|
168
|
+
title: `完了済み ${memberNodes.length}件`,
|
|
169
|
+
lane: dominantLane(memberNodes),
|
|
170
|
+
status: 'done',
|
|
171
|
+
plan_schema: memberNodes[0].plan_schema,
|
|
172
|
+
phase_id: null,
|
|
173
|
+
phase_status: null,
|
|
174
|
+
phase_ready: false,
|
|
175
|
+
fold: {
|
|
176
|
+
task_count: memberNodes.length,
|
|
177
|
+
lanes,
|
|
178
|
+
longest_chain_task_count: memberNodes
|
|
179
|
+
.filter((node) => longestChainKeys.has(node.key)).length,
|
|
180
|
+
task_refs: memberNodes.map((node) => ({ ...node.ref })),
|
|
181
|
+
},
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
return summaryByUnit;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function contract(nodes, edges, unitByNode, summaryByUnit) {
|
|
188
|
+
const mapKey = (key) => {
|
|
189
|
+
const unitKey = unitByNode.get(key);
|
|
190
|
+
return unitKey === undefined ? key : summaryByUnit.get(unitKey).key;
|
|
191
|
+
};
|
|
192
|
+
const keptNodes = nodes.filter((node) => !unitByNode.has(node.key));
|
|
193
|
+
const summaries = [...summaryByUnit.values()];
|
|
194
|
+
const resultNodes = [...keptNodes, ...summaries]
|
|
195
|
+
.sort((left, right) => compareText(left.key, right.key));
|
|
196
|
+
|
|
197
|
+
const contracted = new Map();
|
|
198
|
+
for (const edge of edges) {
|
|
199
|
+
const from = mapKey(edge.from);
|
|
200
|
+
const to = mapKey(edge.to);
|
|
201
|
+
if (from === to) continue; // interior of a fold unit
|
|
202
|
+
const key = JSON.stringify([from, to]);
|
|
203
|
+
let merged = contracted.get(key);
|
|
204
|
+
if (merged === undefined) {
|
|
205
|
+
// A rewired edge is an aggregate. Join identity describes how several
|
|
206
|
+
// premises meet at one ToDo; carrying it onto an aggregate edge would
|
|
207
|
+
// draw a junction marker for a join whose members are no longer on
|
|
208
|
+
// screen, so aggregates keep only the dependency kinds.
|
|
209
|
+
merged = { key, from, to, kinds: new Set(), joinIdentities: new Map(), aggregated: false };
|
|
210
|
+
contracted.set(key, merged);
|
|
211
|
+
}
|
|
212
|
+
for (const kind of edge.kinds) merged.kinds.add(kind);
|
|
213
|
+
if (from === edge.from && to === edge.to) {
|
|
214
|
+
for (const [identityKey, identity] of edge.joinIdentities) {
|
|
215
|
+
merged.joinIdentities.set(identityKey, identity);
|
|
216
|
+
}
|
|
217
|
+
} else {
|
|
218
|
+
merged.aggregated = true;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
for (const edge of contracted.values()) {
|
|
222
|
+
if (edge.aggregated) edge.joinIdentities = new Map();
|
|
223
|
+
}
|
|
224
|
+
const resultEdges = [...contracted.values()].sort((left, right) => compareText(left.key, right.key));
|
|
225
|
+
return { nodes: resultNodes, edges: resultEdges };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Kahn peel; returns true when the graph is acyclic. */
|
|
229
|
+
function isAcyclic(nodes, edges) {
|
|
230
|
+
const indegree = new Map(nodes.map(({ key }) => [key, 0]));
|
|
231
|
+
const outgoing = new Map(nodes.map(({ key }) => [key, []]));
|
|
232
|
+
for (const edge of edges) {
|
|
233
|
+
outgoing.get(edge.from).push(edge.to);
|
|
234
|
+
indegree.set(edge.to, indegree.get(edge.to) + 1);
|
|
235
|
+
}
|
|
236
|
+
const ready = nodes.filter(({ key }) => indegree.get(key) === 0).map(({ key }) => key);
|
|
237
|
+
let visited = 0;
|
|
238
|
+
while (ready.length > 0) {
|
|
239
|
+
const current = ready.pop();
|
|
240
|
+
visited += 1;
|
|
241
|
+
for (const next of outgoing.get(current)) {
|
|
242
|
+
indegree.set(next, indegree.get(next) - 1);
|
|
243
|
+
if (indegree.get(next) === 0) ready.push(next);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return visited === nodes.length;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Project the full dependency graph onto the `live` scope.
|
|
251
|
+
*
|
|
252
|
+
* Contracting a weakly connected component can close a cycle: with a fold
|
|
253
|
+
* distance of 1 the shape `f1 -> s -> f2` is reachable, where `s` is a
|
|
254
|
+
* completed ToDo kept as the direct premise of live work and `f1`/`f2` belong
|
|
255
|
+
* to the same fold unit. Contracting that unit would produce
|
|
256
|
+
* `summary -> s -> summary`. So the contraction is verified, and on a cycle the
|
|
257
|
+
* grouping is refined to (component, wave) — provably acyclic, because every
|
|
258
|
+
* dependency edge strictly increases the wave.
|
|
259
|
+
*
|
|
260
|
+
* @returns {{nodes: Array, edges: Array, foldedByKey: Map, folds: Array, refined: boolean}}
|
|
261
|
+
*/
|
|
262
|
+
export function projectTodoGanttScope({
|
|
263
|
+
nodes, edges, wave, longestChainKeys = new Set(), foldDistance = DEFAULT_FOLD_DISTANCE,
|
|
264
|
+
}) {
|
|
265
|
+
const distance = distanceToLive(nodes, edges, wave);
|
|
266
|
+
const foldable = foldableKeys(nodes, distance, foldDistance);
|
|
267
|
+
if (foldable.size === 0) {
|
|
268
|
+
return { nodes, edges, foldedByKey: new Map(), folds: [], refined: false };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
let refined = false;
|
|
272
|
+
let grouping = groupFoldable(nodes, edges, foldable, wave, false);
|
|
273
|
+
let summaryByUnit = buildSummaryNodes(nodes, grouping.members, longestChainKeys);
|
|
274
|
+
let contracted = contract(nodes, edges, grouping.unitByNode, summaryByUnit);
|
|
275
|
+
if (!isAcyclic(contracted.nodes, contracted.edges)) {
|
|
276
|
+
refined = true;
|
|
277
|
+
grouping = groupFoldable(nodes, edges, foldable, wave, true);
|
|
278
|
+
summaryByUnit = buildSummaryNodes(nodes, grouping.members, longestChainKeys);
|
|
279
|
+
contracted = contract(nodes, edges, grouping.unitByNode, summaryByUnit);
|
|
280
|
+
if (!isAcyclic(contracted.nodes, contracted.edges)) {
|
|
281
|
+
throw new TodoGanttScopeError('TODO_SCOPE_CONTRACTION_CYCLIC',
|
|
282
|
+
'todo gantt scope contraction produced a cycle after per-wave refinement',
|
|
283
|
+
{ fold_unit_count: grouping.members.size });
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const foldedByKey = new Map();
|
|
288
|
+
for (const [nodeKey, unitKey] of grouping.unitByNode) {
|
|
289
|
+
foldedByKey.set(nodeKey, summaryByUnit.get(unitKey).key);
|
|
290
|
+
}
|
|
291
|
+
const folds = [...summaryByUnit.values()]
|
|
292
|
+
.sort((left, right) => compareText(left.key, right.key))
|
|
293
|
+
.map((summary) => ({ ref: { ...summary.ref }, ...summary.fold }));
|
|
294
|
+
|
|
295
|
+
return { nodes: contracted.nodes, edges: contracted.edges, foldedByKey, folds, refined };
|
|
296
|
+
}
|
package/src/todo-gantt-svg.mjs
CHANGED
|
@@ -140,28 +140,39 @@ function presentationMaps(presentation) {
|
|
|
140
140
|
function renderNode(node, maps) {
|
|
141
141
|
if (!node.visible || node.geometry === null) return '';
|
|
142
142
|
const { x, y, width, height } = node.geometry;
|
|
143
|
+
const fold = node.fold ?? null;
|
|
143
144
|
const classes = ['todo-node', STATUS_CLASSES[node.status] ?? 'status-unknown'];
|
|
145
|
+
if (fold !== null) classes.push('folded-node');
|
|
144
146
|
if (node.visibility.longest_dependency_chain) classes.push('longest-chain-node');
|
|
145
147
|
if (node.visibility.active) classes.push('active-node');
|
|
146
148
|
if (node.visibility.next_ready) classes.push('next-ready-node');
|
|
147
149
|
if (node.visibility.selected) classes.push('selected-node');
|
|
148
150
|
const key = nodeKey(node.ref);
|
|
149
151
|
const nodeLaneKey = laneKey(node.ref.plan_key, node.lane);
|
|
150
|
-
const status =
|
|
152
|
+
const status = fold === null
|
|
153
|
+
? TODO_GANTT_STATUS_PRESENTATION[node.status] ?? { mark: '?', label: '状態不明' }
|
|
154
|
+
: { mark: '▣', label: '完走済み(畳み込み)' };
|
|
151
155
|
const lane = maps.lanes.get(nodeLaneKey);
|
|
152
|
-
const laneLabel =
|
|
156
|
+
const laneLabel = fold === null
|
|
157
|
+
? (lane === undefined ? node.lane : `${node.lane}、${lane.name}`)
|
|
158
|
+
: fold.lanes.join('、');
|
|
153
159
|
const taskNumber = maps.taskNumbers.get(key);
|
|
154
|
-
const visibleReference =
|
|
155
|
-
|
|
160
|
+
const visibleReference = fold !== null ? `${fold.task_count}工程`
|
|
161
|
+
: taskNumber === undefined ? `ID ${node.ref.task_id}` : `工程 ${taskNumber.display_number}`;
|
|
162
|
+
const spokenReference = fold !== null ? `${fold.task_count}工程の畳み込み`
|
|
163
|
+
: taskNumber === undefined ? `ID ${node.ref.task_id}` : `工程${taskNumber.display_number}`;
|
|
156
164
|
const readyLabel = node.visibility.next_ready ? '。ready frontierの同時dispatch候補' : '';
|
|
157
|
-
const
|
|
165
|
+
const identity = fold === null
|
|
166
|
+
? `正規ID ${node.ref.plan_key}/${node.ref.task_id}`
|
|
167
|
+
: `${node.ref.plan_key}の完走済み${fold.task_count}工程をまとめたノード。全件を描くには --scope all`;
|
|
168
|
+
const ariaLabel = `${spokenReference}。${status.label}。${laneLabel}。${node.title}。${identity}${readyLabel}`;
|
|
158
169
|
const statusBar = node.status === 'in-progress'
|
|
159
170
|
? `<line class="status-bar" x1="${x + 5}" y1="${y + 6}" x2="${x + 5}" y2="${y + height - 6}"></line>` : '';
|
|
160
171
|
const titleLines = wrapLabel(node.title);
|
|
161
172
|
const titleMarkup = titleLines.map((line, index) => `<tspan x="${x + 10}" dy="${index === 0 ? 0 : 17}" class="node-title-line">${escapeSvgText(line)}</tspan>`).join('');
|
|
162
173
|
const taskNumberAttributes = taskNumber === undefined ? ''
|
|
163
174
|
: ` data-task-number="${escapeSvgAttribute(taskNumber.display_number)}" data-task-number-normalized="${escapeSvgAttribute(taskNumber.normalized_number)}" data-task-number-globally-unique="${taskNumber.globally_unique ? 'true' : 'false'}"`;
|
|
164
|
-
return `<g class="${classes.join(' ')}" data-node-key="${escapeSvgAttribute(key)}" data-lane-key="${escapeSvgAttribute(nodeLaneKey)}" data-project-id="${escapeSvgAttribute(node.ref.project_id)}" data-plan-key="${escapeSvgAttribute(node.ref.plan_key)}" data-task-id="${escapeSvgAttribute(node.ref.task_id)}"${taskNumberAttributes} tabindex="0" role="button" aria-selected="${node.visibility.selected ? 'true' : 'false'}" aria-label="${escapeSvgAttribute(ariaLabel)}"><rect class="node-surface" x="${x}" y="${y}" width="${width}" height="${height}" rx="4"></rect>${statusBar}<text class="status-mark" x="${x + 10}" y="${y + 21}">${escapeSvgText(status.mark)}</text><text class="node-meta" x="${x + 34}" y="${y + 20}">${escapeSvgText(`${status.label} · ${visibleReference}`)}</text><text class="node-title" x="${x + 10}" y="${y + 42}">${titleMarkup}</text><title>${escapeSvgText(`${spokenReference}: ${node.title} — ${status.label} — ${laneLabel} —
|
|
175
|
+
return `<g class="${classes.join(' ')}" data-node-key="${escapeSvgAttribute(key)}" data-lane-key="${escapeSvgAttribute(nodeLaneKey)}" data-project-id="${escapeSvgAttribute(node.ref.project_id)}" data-plan-key="${escapeSvgAttribute(node.ref.plan_key)}" data-task-id="${escapeSvgAttribute(node.ref.task_id)}"${taskNumberAttributes} tabindex="0" role="button" aria-selected="${node.visibility.selected ? 'true' : 'false'}" aria-label="${escapeSvgAttribute(ariaLabel)}"><rect class="node-surface" x="${x}" y="${y}" width="${width}" height="${height}" rx="4"></rect>${statusBar}<text class="status-mark" x="${x + 10}" y="${y + 21}">${escapeSvgText(status.mark)}</text><text class="node-meta" x="${x + 34}" y="${y + 20}">${escapeSvgText(`${status.label} · ${visibleReference}`)}</text><text class="node-title" x="${x + 10}" y="${y + 42}">${titleMarkup}</text><title>${escapeSvgText(`${spokenReference}: ${node.title} — ${status.label} — ${laneLabel} — ${identity}`)}</title></g>`;
|
|
165
176
|
}
|
|
166
177
|
|
|
167
178
|
function summaryLabel(value, maximum = 34) {
|
package/src/todo-store.mjs
CHANGED
|
@@ -1122,13 +1122,14 @@ function resolveTargetedEvent(input, storeMember) {
|
|
|
1122
1122
|
if (input.kind === 'done' && exactRecord(input.payload, [
|
|
1123
1123
|
'done_mode', 'imported', 'evidence',
|
|
1124
1124
|
]) && input.payload.done_mode === 'evidence_promotion' && input.payload.imported === true) {
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1125
|
+
// Same binding as `reopen`: an imported completion carried across a revision
|
|
1126
|
+
// is bound to the carrying `plan_genesis`, not to a `done` event. Whether the
|
|
1127
|
+
// completion is actually eligible for promotion stays replay's decision.
|
|
1128
|
+
const targetDigest = resolveDoneBindingDigest(storeMember, input.task_id);
|
|
1129
|
+
if (targetDigest === null) fail('STORE_INCONSISTENT', 'invalid_evidence_promotion');
|
|
1129
1130
|
return {
|
|
1130
1131
|
...input,
|
|
1131
|
-
payload: { ...input.payload, target_done_digest:
|
|
1132
|
+
payload: { ...input.payload, target_done_digest: targetDigest },
|
|
1132
1133
|
};
|
|
1133
1134
|
}
|
|
1134
1135
|
if (input.kind === 'phase_reopen' && exactRecord(input.payload, ['reason', 'override_reason'])) {
|