crewforth 3.1.1 → 3.2.0-rc.2
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 +171 -0
- package/README.md +4 -3
- package/VERSION +1 -1
- package/adopt.sh +56 -5
- package/kit/AGENT_TEMPLATE.md +2 -1
- package/kit/CLAUDE.md +14 -13
- package/kit/agents/crew-commit-agent.md +7 -2
- package/kit/agents/crew-docs-agent.md +71 -0
- package/kit/agents/crew-planner.md +2 -0
- package/kit/agents/crew-refuter.md +77 -0
- package/kit/agents/crew-security-expert.md +3 -1
- package/kit/agents/crew-session-manager.md +11 -9
- package/kit/eval/doctor.sh +117 -6
- package/kit/eval/golden-routing.txt +9 -0
- package/kit/eval/lib/crew-map/crew-map.sh +26 -0
- package/kit/eval/lib/crew-map/detect.mjs +238 -0
- package/kit/eval/lib/crew-map/narrative.mjs +98 -0
- package/kit/eval/lib/crew-map/parse.mjs +305 -0
- package/kit/eval/lib/crew-map/project.mjs +163 -0
- package/kit/eval/lib/crew-map/rules/names.json +38 -0
- package/kit/eval/lib/crew-map/rules/packages.json +142 -0
- package/kit/eval/lib/crew-map/rules/python-stdlib.json +197 -0
- package/kit/eval/lib/crew-map/scan.mjs +214 -0
- package/kit/eval/lib/crew-map/stacks.mjs +441 -0
- package/kit/eval/lib/crew-map/surface.mjs +457 -0
- package/kit/eval/lib/findings/findings.mjs +410 -0
- package/kit/eval/lib/findings/findings.schema.json +122 -0
- package/kit/eval/lib/findings/findings.sh +29 -0
- package/kit/eval/lib/settings-default.sh +36 -0
- package/kit/eval/lib/settings-json.awk +15 -4
- package/kit/eval/lib/star.sh +23 -7
- package/kit/eval/lib/statusline.settings.json +6 -0
- package/kit/eval/lib/statusline.sh +100 -0
- package/kit/eval/lib/version.sh +41 -0
- package/kit/eval/smoke-test.sh +576 -41
- package/kit/hooks/agent-outcome.sh +26 -12
- package/kit/hooks/guard-agent-model.sh +109 -9
- package/kit/hooks/guard-bash.sh +240 -182
- package/kit/hooks/guard-question.sh +73 -0
- package/kit/hooks/guard-write.sh +21 -0
- package/kit/hooks/prompt-approval.sh +320 -103
- package/kit/hooks/refuter-ledger.sh +80 -0
- package/kit/hooks/session-update-check.sh +9 -8
- package/kit/owned-blobs.tsv +4 -4
- package/kit/settings.json +33 -0
- package/kit/skills/crew-approve/SKILL.md +7 -0
- package/kit/skills/crew-doctor/SKILL.md +5 -1
- package/kit/skills/crew-handoff/SKILL.md +3 -2
- package/kit/skills/crew-map/SKILL.md +44 -0
- package/kit/skills/crew-review/SKILL.md +2 -0
- package/kit/skills/crew-update/SKILL.md +1 -1
- package/kit/skills/incident-runbook/SKILL.md +2 -2
- package/kit/skills/security-scan/references/verify.md +6 -3
- package/kit/studio/README.md +12 -0
- package/kit/studio/server/index.js +125 -11
- package/kit/studio/server/lib/code-stamp.js +35 -0
- package/kit/studio/server/lib/crew-map.js +113 -0
- package/kit/studio/server/lib/model-outcomes.js +130 -15
- package/kit/studio/server/lib/owned-stream.js +56 -0
- package/kit/studio/server/lib/pricing.js +3 -0
- package/kit/studio/server/lib/projects.js +11 -4
- package/kit/studio/server/lib/remote.js +99 -0
- package/kit/studio/server/lib/usage.js +74 -14
- package/kit/studio/web/app.js +223 -15
- package/kit/studio/web/canvas.js +44 -120
- package/kit/studio/web/chat.js +82 -17
- package/kit/studio/web/graph-plan.js +10 -4
- package/kit/studio/web/index.html +10 -5
- package/kit/studio/web/liveness.js +14 -0
- package/kit/studio/web/map-plan.js +341 -0
- package/kit/studio/web/map.js +503 -0
- package/kit/studio/web/models-plan.js +61 -7
- package/kit/studio/web/models.js +19 -3
- package/kit/studio/web/style.css +131 -0
- package/kit/studio/web/usage.js +12 -0
- package/kit/studio/web/viewport.js +160 -0
- package/package.json +6 -5
- package/start.sh +10 -0
|
@@ -0,0 +1,503 @@
|
|
|
1
|
+
// The Map view: the project as Crewforth's crew map shows it.
|
|
2
|
+
//
|
|
3
|
+
// Bands from the clients down to the external resources, on a pane that pans and zooms like the graph's (the
|
|
4
|
+
// shared viewport.js). Lines are solid where a file proves them, dotted where they are inferred, dashed with their
|
|
5
|
+
// own mark where the model proposed them. A card can be placed by hand and stays placed for that project; modules
|
|
6
|
+
// that share a folder can be folded into one card. Under the pane, the findings; beside it, the inspector. On a
|
|
7
|
+
// phone the pane gives way to the findings and a list of the modules. It reads the map the server sends and writes
|
|
8
|
+
// nothing; what leaves the page is copied text: a finding as a task, or a file:line.
|
|
9
|
+
import {
|
|
10
|
+
nodesOf, edgesOf, edgeKey, litEdges, findingsOf, findingCounts, cardSub, summaryOf, sourceOf, inspectOf, taskOf,
|
|
11
|
+
emptyNote, layoutOf, pathOf, chipsOf, resourceWord, inferredOnly, groupsOf, foldView, placed, BANDS,
|
|
12
|
+
} from './map-plan.js';
|
|
13
|
+
import { Viewport, draggable, swallowClick } from './viewport.js';
|
|
14
|
+
|
|
15
|
+
const SVG = 'http://www.w3.org/2000/svg';
|
|
16
|
+
export const LAYOUT_KEY = 'crewforth-studio-map-layout:';
|
|
17
|
+
|
|
18
|
+
function mk(tag, cls, text) {
|
|
19
|
+
const n = document.createElement(tag);
|
|
20
|
+
if (cls) n.className = cls;
|
|
21
|
+
if (text != null) n.textContent = text;
|
|
22
|
+
return n;
|
|
23
|
+
}
|
|
24
|
+
function svg(tag, attrs = {}) {
|
|
25
|
+
const n = document.createElementNS(SVG, tag);
|
|
26
|
+
for (const [k, v] of Object.entries(attrs)) n.setAttribute(k, String(v));
|
|
27
|
+
return n;
|
|
28
|
+
}
|
|
29
|
+
const BAND_ORDER = Object.fromEntries(BANDS.map(([k], i) => [k, i]));
|
|
30
|
+
const BAND_WORD = Object.fromEntries(BANDS);
|
|
31
|
+
|
|
32
|
+
const LAYERS = [['modules', 'Modules'], ['external', 'External'], ['endpoints', 'Endpoints'], ['findings', 'Findings']];
|
|
33
|
+
|
|
34
|
+
export class MapView {
|
|
35
|
+
/**
|
|
36
|
+
* @param hooks.onCopy(text, what) put text on the clipboard; `what` ('task' or 'path') is for the page's toast
|
|
37
|
+
* @param hooks.onCompare(cwd) read the map again, compared with that project's (null: with none)
|
|
38
|
+
* @param hooks.onZoom(k) the pane's zoom changed
|
|
39
|
+
*/
|
|
40
|
+
constructor(root, hooks = {}) {
|
|
41
|
+
this.root = root;
|
|
42
|
+
this.hooks = hooks;
|
|
43
|
+
this.data = null;
|
|
44
|
+
this.layers = { modules: true, external: true, endpoints: false, findings: true };
|
|
45
|
+
this.showHidden = false;
|
|
46
|
+
this.selected = null;
|
|
47
|
+
this.tab = 'overview';
|
|
48
|
+
this.legendOpen = false;
|
|
49
|
+
this.pins = {};
|
|
50
|
+
this.folded = new Set();
|
|
51
|
+
this.groupOf = {};
|
|
52
|
+
this.lay = null;
|
|
53
|
+
this.fitted = false;
|
|
54
|
+
this.root.classList.add('mp');
|
|
55
|
+
this.#build();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The parts that live as long as the view: the pane and the layer it moves keep the zoom across redraws. */
|
|
59
|
+
#build() {
|
|
60
|
+
this.top = mk('div', 'mp-top');
|
|
61
|
+
this.pane = mk('div', 'mp-canvas-pane');
|
|
62
|
+
this.layer = mk('div', 'mp-canvas');
|
|
63
|
+
this.pane.append(this.layer);
|
|
64
|
+
this.vp = new Viewport(this.pane, this.layer, {
|
|
65
|
+
skip: (t) => Boolean(t.closest?.('.mp-card') || t.closest?.('.mp-group-fold')),
|
|
66
|
+
onApply: () => this.hooks.onZoom?.(this.vp.view.k),
|
|
67
|
+
});
|
|
68
|
+
this.below = mk('div', 'mp-below');
|
|
69
|
+
this.list = mk('section', 'mp-modlist');
|
|
70
|
+
this.list.setAttribute('aria-label', 'Modules');
|
|
71
|
+
this.side = mk('aside', 'mp-inspect');
|
|
72
|
+
this.side.setAttribute('aria-label', 'Selected node');
|
|
73
|
+
const main = mk('div', 'mp-main');
|
|
74
|
+
main.append(this.pane, this.below, this.list);
|
|
75
|
+
this.body = mk('div', 'mp-body');
|
|
76
|
+
this.body.append(main, this.side);
|
|
77
|
+
this.frame = mk('section', 'mp-pane');
|
|
78
|
+
this.frame.append(this.top, this.body);
|
|
79
|
+
this.empty = mk('div', 'mp-empty');
|
|
80
|
+
// The pane has no size until it is on screen, so the first Fit is done again when it gets one, and whenever it
|
|
81
|
+
// changes size while the viewer has not moved the view or placed a card: the graph does the same.
|
|
82
|
+
if (typeof ResizeObserver !== 'undefined') {
|
|
83
|
+
new ResizeObserver(() => { if (this.lay && !this.vp.touched && !this.moved) { this.layWidth = null; this.render(); this.fit(); } }).observe(this.pane);
|
|
84
|
+
}
|
|
85
|
+
// A press anywhere outside the open legend closes it, as the design says: a popover, not a window.
|
|
86
|
+
document.addEventListener?.('pointerdown', (e) => {
|
|
87
|
+
if (this.legendOpen && !e.target?.closest?.('.mp-legend')) { this.legendOpen = false; this.render(); }
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
setData(data) {
|
|
92
|
+
if (data?.cwd !== this.data?.cwd) {
|
|
93
|
+
this.selected = null; this.tab = 'overview'; this.fitted = false; this.layWidth = null;
|
|
94
|
+
this.#restore(data?.cwd);
|
|
95
|
+
}
|
|
96
|
+
this.data = data;
|
|
97
|
+
this.render();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Select a node; with `reveal`, bring its card to the middle of the pane. */
|
|
101
|
+
select(id, { reveal = false } = {}) {
|
|
102
|
+
const drawn = id ? (this.lay?.pos[id] ? id : this.groupOf[id] ?? id) : null;
|
|
103
|
+
this.selected = id; this.tab = 'overview';
|
|
104
|
+
this.render();
|
|
105
|
+
const p = drawn ? this.lay?.pos[drawn] : null;
|
|
106
|
+
if (reveal && p) this.vp.centerOn(p.x + p.w / 2, p.y + p.h / 2, 1);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Esc closes the legend first, then lets the selection go. Returns whether it had something to close. */
|
|
110
|
+
escape() {
|
|
111
|
+
if (this.legendOpen) { this.legendOpen = false; this.render(); return true; }
|
|
112
|
+
if (!this.selected) return false;
|
|
113
|
+
this.selected = null; this.render(); return true;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
zoomBy(f) { this.vp.zoomBy(f); }
|
|
117
|
+
zoomTo(k) { this.vp.zoomTo(k); }
|
|
118
|
+
fit() { if (this.lay) this.vp.fit(this.lay.width, this.lay.height); }
|
|
119
|
+
get zoom() { return this.vp.view.k; }
|
|
120
|
+
/** How many cards were placed by hand: what Reset layout would forget. */
|
|
121
|
+
get moved() { return Object.keys(this.pins).length; }
|
|
122
|
+
/** Forget every card placed by hand. Returns what was forgotten, for Undo; null when there was nothing. */
|
|
123
|
+
resetLayout() {
|
|
124
|
+
if (!this.moved) return null;
|
|
125
|
+
const was = { ...this.pins };
|
|
126
|
+
this.pins = {}; this.#persist(); this.render(); this.fit();
|
|
127
|
+
return was;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
restoreLayout(was) {
|
|
131
|
+
if (!was) return false;
|
|
132
|
+
this.pins = { ...was }; this.#persist(); this.render();
|
|
133
|
+
return true;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
toggleGroup(id) {
|
|
137
|
+
if (this.folded.has(id)) this.folded.delete(id); else this.folded.add(id);
|
|
138
|
+
this.#persist();
|
|
139
|
+
this.render();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
render() {
|
|
143
|
+
if (this.root.hidden) return;
|
|
144
|
+
const note = emptyNote(this.data);
|
|
145
|
+
if (note) {
|
|
146
|
+
this.empty.replaceChildren(mk('p', null, note));
|
|
147
|
+
if (this.data?.missing) this.empty.append(mk('p', 'sub', 'Studio only reads the map: nothing is scanned from the panel.'));
|
|
148
|
+
this.root.replaceChildren(this.empty);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
if (this.root.children?.[0] !== this.frame) this.root.replaceChildren(this.frame);
|
|
152
|
+
const d = this.data;
|
|
153
|
+
const doc = d.doc;
|
|
154
|
+
const findings = findingsOf(doc, { showHidden: this.showHidden, compare: d.compare });
|
|
155
|
+
const counts = this.layers.findings ? findingCounts(findings.shown) : {};
|
|
156
|
+
const allEdges = edgesOf(doc, d.compare);
|
|
157
|
+
const all = nodesOf(doc, d.compare);
|
|
158
|
+
let nodes = all.filter((n) => (n.kind === 'external' ? this.layers.external : this.layers.modules));
|
|
159
|
+
const groups = groupsOf(nodes);
|
|
160
|
+
this.groupOf = {};
|
|
161
|
+
for (const g of Object.values(groups)) for (const m of g.members) if (this.folded.has(g.id)) this.groupOf[m] = g.id;
|
|
162
|
+
const sortKey = {};
|
|
163
|
+
for (const g of Object.values(groups)) for (const m of g.members) sortKey[m] = g.id;
|
|
164
|
+
// Members of one folder side by side, so an open group can be framed.
|
|
165
|
+
nodes = [...nodes].sort((a, b) => (BAND_ORDER[a.band] - BAND_ORDER[b.band]) || String(sortKey[a.id] ?? a.id).localeCompare(String(sortKey[b.id] ?? b.id)));
|
|
166
|
+
const ids = new Set(nodes.map((n) => n.id));
|
|
167
|
+
const view = foldView(nodes, allEdges.filter((e) => ids.has(e.from) && ids.has(e.to)), groups, this.folded);
|
|
168
|
+
nodes = view.nodes;
|
|
169
|
+
const edges = view.edges;
|
|
170
|
+
for (const n of nodes) if (n.kind === 'group') counts[n.id] = n.members.reduce((s, m) => s + (counts[m] ?? 0), 0);
|
|
171
|
+
const chips = {};
|
|
172
|
+
if (this.layers.endpoints) for (const n of nodes) if (n.kind === 'module') chips[n.id] = chipsOf(n, doc);
|
|
173
|
+
// The width cards wrap at is taken once per map and per pane size, not on every redraw: a click must not move
|
|
174
|
+
// the cards it did not touch.
|
|
175
|
+
this.layWidth ??= Math.max(720, (this.pane.clientWidth || 1100) - 24);
|
|
176
|
+
const width = this.layWidth;
|
|
177
|
+
const openGroup = (n) => (sortKey[n.id] && !this.folded.has(sortKey[n.id]) ? sortKey[n.id] : null);
|
|
178
|
+
this.lay = placed(layoutOf(nodes, { width, chips: Object.fromEntries(Object.entries(chips).map(([k, v]) => [k, v.length])), rowKey: openGroup }), this.pins);
|
|
179
|
+
const lay = this.lay;
|
|
180
|
+
const shownSel = this.selected ? (lay.pos[this.selected] ? this.selected : this.groupOf[this.selected] ?? null) : null;
|
|
181
|
+
const lit = litEdges(edges, shownSel);
|
|
182
|
+
|
|
183
|
+
this.top.replaceChildren(this.#head(doc, findings), this.#toolbar(d), this.#source(d));
|
|
184
|
+
|
|
185
|
+
this.layer.style.width = `${lay.width}px`;
|
|
186
|
+
this.layer.style.height = `${lay.height}px`;
|
|
187
|
+
const parts = [];
|
|
188
|
+
for (const b of lay.bands) {
|
|
189
|
+
const band = mk('div', 'mp-band');
|
|
190
|
+
band.style.top = `${b.y}px`; band.style.height = `${b.h}px`; band.style.width = `${lay.width}px`;
|
|
191
|
+
band.dataset.band = b.key;
|
|
192
|
+
band.append(mk('span', 'lbl', b.label));
|
|
193
|
+
parts.push(band);
|
|
194
|
+
}
|
|
195
|
+
// An open group is framed around its members, with the control that folds it.
|
|
196
|
+
for (const g of Object.values(groups)) {
|
|
197
|
+
if (this.folded.has(g.id)) continue;
|
|
198
|
+
const boxes = g.members.map((m) => lay.pos[m]).filter(Boolean);
|
|
199
|
+
if (boxes.length < 2) continue;
|
|
200
|
+
const x = Math.min(...boxes.map((p) => p.x)) - 8; const y = Math.min(...boxes.map((p) => p.y)) - 24;
|
|
201
|
+
const w = Math.max(...boxes.map((p) => p.x + p.w)) - x + 8; const h = Math.max(...boxes.map((p) => p.y + p.h)) - y + 8;
|
|
202
|
+
const frame = mk('div', 'mp-group');
|
|
203
|
+
frame.dataset.group = g.id;
|
|
204
|
+
frame.style.left = `${x}px`; frame.style.top = `${y}px`; frame.style.width = `${w}px`; frame.style.height = `${h}px`;
|
|
205
|
+
const fold = mk('button', 'mp-group-fold', `${g.folder}/ · fold`);
|
|
206
|
+
fold.type = 'button';
|
|
207
|
+
fold.title = `Fold the ${g.members.length} modules under ${g.folder}/ into one card`;
|
|
208
|
+
fold.addEventListener('click', () => this.toggleGroup(g.id));
|
|
209
|
+
frame.append(fold);
|
|
210
|
+
parts.push(frame);
|
|
211
|
+
}
|
|
212
|
+
const lines = svg('svg', { class: 'mp-lines', width: lay.width, height: lay.height, 'aria-hidden': 'true' });
|
|
213
|
+
const labels = [];
|
|
214
|
+
for (const e of edges) {
|
|
215
|
+
const p = pathOf(lay.pos[e.from], lay.pos[e.to]);
|
|
216
|
+
if (!p) continue;
|
|
217
|
+
const path = svg('path', { d: p.d, class: 'mp-line' });
|
|
218
|
+
path.dataset.solid = String(e.solid);
|
|
219
|
+
path.dataset.line = e.line;
|
|
220
|
+
if (shownSel) path.dataset.lit = String(lit.has(edgeKey(e)));
|
|
221
|
+
lines.append(path);
|
|
222
|
+
const tags = [];
|
|
223
|
+
if (e.line === 'model') { const m = mk('span', 'mp-pill', 'model'); m.dataset.tone = 'model'; m.title = 'Proposed by the model, not shown by a file'; tags.push(m); }
|
|
224
|
+
if (e.calls) {
|
|
225
|
+
tags.push(mk('span', 'mp-pill', `${e.calls} ${e.calls === 1 ? 'call' : 'calls'}`));
|
|
226
|
+
if (e.missing) { const m = mk('span', 'mp-pill', `${e.missing} missing`); m.dataset.tone = 'missing'; tags.push(m); }
|
|
227
|
+
}
|
|
228
|
+
if (tags.length) {
|
|
229
|
+
const tag = mk('div', 'mp-elab');
|
|
230
|
+
tag.style.left = `${p.mid[0]}px`; tag.style.top = `${p.mid[1]}px`;
|
|
231
|
+
tag.append(...tags);
|
|
232
|
+
labels.push(tag);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
parts.push(lines, ...labels);
|
|
236
|
+
for (const n of nodes) parts.push(this.#card(n, doc, lay.pos[n.id], counts[n.id] ?? 0, chips[n.id], edges, shownSel));
|
|
237
|
+
this.layer.replaceChildren(...parts);
|
|
238
|
+
if (!this.fitted) { this.fitted = true; this.fit(); } else this.vp.apply();
|
|
239
|
+
|
|
240
|
+
this.below.replaceChildren(...(this.layers.findings ? [this.#findings(findings)] : []));
|
|
241
|
+
this.#modList(all, counts);
|
|
242
|
+
const picked = this.selected ? all.find((n) => n.id === this.selected) ?? null : null;
|
|
243
|
+
this.#inspector(picked, doc, allEdges, findings.shown);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
#head(doc, findings) {
|
|
247
|
+
const head = mk('header', 'mp-head');
|
|
248
|
+
head.append(mk('h2', 'mp-title', 'Map'), mk('span', 'row-fill'));
|
|
249
|
+
for (const s of summaryOf(doc, findings)) head.append(mk('span', 'mp-pill', s));
|
|
250
|
+
return head;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
#toolbar(d) {
|
|
254
|
+
const bar = mk('div', 'mp-toolbar');
|
|
255
|
+
bar.append(mk('span', 'lbl', 'Layers'));
|
|
256
|
+
for (const [k, label] of LAYERS) {
|
|
257
|
+
const b = mk('button', 'mp-tog', label);
|
|
258
|
+
b.type = 'button';
|
|
259
|
+
b.setAttribute('aria-pressed', String(this.layers[k]));
|
|
260
|
+
b.addEventListener('click', () => { this.layers[k] = !this.layers[k]; this.render(); });
|
|
261
|
+
bar.append(b);
|
|
262
|
+
}
|
|
263
|
+
bar.append(mk('span', 'row-fill'));
|
|
264
|
+
const pick = mk('select', 'mp-compare');
|
|
265
|
+
pick.setAttribute('aria-label', 'Compare with');
|
|
266
|
+
const none = mk('option', null, d.others?.length ? 'Compare with…' : 'Compare with: no other project has a map');
|
|
267
|
+
none.value = '';
|
|
268
|
+
pick.append(none);
|
|
269
|
+
for (const o of d.others ?? []) { const opt = mk('option', null, o.label); opt.value = o.cwd; pick.append(opt); }
|
|
270
|
+
pick.value = d.compare?.cwd ?? '';
|
|
271
|
+
pick.disabled = !d.others?.length;
|
|
272
|
+
pick.addEventListener('change', () => this.hooks.onCompare?.(pick.value || null));
|
|
273
|
+
const legend = mk('div', 'mp-legend');
|
|
274
|
+
const lb = mk('button', 'btn sm ghost', 'Legend');
|
|
275
|
+
lb.type = 'button';
|
|
276
|
+
lb.setAttribute('aria-expanded', String(this.legendOpen));
|
|
277
|
+
lb.addEventListener('click', () => { this.legendOpen = !this.legendOpen; this.render(); });
|
|
278
|
+
legend.append(lb);
|
|
279
|
+
if (this.legendOpen) {
|
|
280
|
+
const lg = mk('div', 'mp-legend-box');
|
|
281
|
+
for (const [kind, text] of [['evidence', 'A file proves this line'], ['inferred', 'Inferred, not proven'], ['model', 'Proposed by the model'], ['lit', 'A line of the selected node']]) {
|
|
282
|
+
const row = mk('div', 'mp-legend-row');
|
|
283
|
+
const s = mk('span', 'mp-legend-line'); s.dataset.kind = kind;
|
|
284
|
+
row.append(s, mk('span', 'sub', text));
|
|
285
|
+
lg.append(row);
|
|
286
|
+
}
|
|
287
|
+
for (const [kind, text] of [['solid', 'A resource a file shows'], ['dashed', 'A resource only inferred']]) {
|
|
288
|
+
const row = mk('div', 'mp-legend-row');
|
|
289
|
+
const s = mk('span', 'mp-legend-card'); s.dataset.kind = kind;
|
|
290
|
+
row.append(s, mk('span', 'sub', text));
|
|
291
|
+
lg.append(row);
|
|
292
|
+
}
|
|
293
|
+
legend.append(lg);
|
|
294
|
+
}
|
|
295
|
+
bar.append(pick, legend);
|
|
296
|
+
return bar;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
#source(d) {
|
|
300
|
+
const s = sourceOf(d);
|
|
301
|
+
const strip = mk('div', 'mp-source');
|
|
302
|
+
strip.append(mk('code', 'mp-file', s.file), mk('span', 'sub', `· ${s.at}`));
|
|
303
|
+
if (s.behind) { const b = mk('span', 'mp-pill', s.behind); b.dataset.tone = 'behind'; strip.append(b); }
|
|
304
|
+
if (s.behindNote) strip.append(mk('span', 'sub', s.behindNote));
|
|
305
|
+
if (d.compare?.reason) strip.append(mk('span', 'sub', `Compare: ${d.compare.reason}`));
|
|
306
|
+
strip.append(mk('span', 'row-fill'), mk('span', 'sub', s.rule));
|
|
307
|
+
return strip;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
#card(n, doc, p, count, chips, edges, shownSel) {
|
|
311
|
+
const card = mk('div', `mp-card ${n.kind === 'external' ? 'mp-ext' : 'mp-mod'}`);
|
|
312
|
+
card.tabIndex = 0;
|
|
313
|
+
card.setAttribute('role', 'button');
|
|
314
|
+
card.dataset.id = n.id;
|
|
315
|
+
card.dataset.kind = n.kind;
|
|
316
|
+
card.style.left = `${p.x}px`; card.style.top = `${p.y}px`; card.style.width = `${p.w}px`; card.style.height = `${p.h}px`;
|
|
317
|
+
if (shownSel === n.id) card.dataset.selected = 'true';
|
|
318
|
+
if (this.pins[n.id]) card.dataset.pinned = 'true';
|
|
319
|
+
if (n.kind === 'external') {
|
|
320
|
+
if (inferredOnly(n, edges)) { card.dataset.inferred = 'true'; card.title = 'Only inferred: no file shows this resource directly'; }
|
|
321
|
+
card.append(mk('span', 'mp-kind', resourceWord(n.resource)), mk('span', 'mp-ename', n.label ?? n.id));
|
|
322
|
+
if (!card.title) card.title = (n.hosts ?? []).join(', ');
|
|
323
|
+
} else {
|
|
324
|
+
const top = mk('span', 'mp-row');
|
|
325
|
+
top.append(mk('span', 'mp-name', n.name ?? n.id), mk('span', 'row-fill'));
|
|
326
|
+
if (count) { const f = mk('span', 'mp-fb', String(count)); f.title = `${count} ${count === 1 ? 'finding' : 'findings'}`; top.append(f); }
|
|
327
|
+
const sub = n.kind === 'group' ? `${n.members.length} modules · click to open` : cardSub(n, doc);
|
|
328
|
+
card.append(top, mk('span', 'sub', sub));
|
|
329
|
+
if (chips?.length) { const c = mk('span', 'mp-chips'); for (const t of chips) c.append(mk('span', 'mp-chip', t)); card.append(c); }
|
|
330
|
+
}
|
|
331
|
+
draggable(card, {
|
|
332
|
+
scale: () => this.vp.view.k,
|
|
333
|
+
at: () => this.lay?.pos[n.id],
|
|
334
|
+
move: (x, y) => { card.style.left = `${x}px`; card.style.top = `${y}px`; this.pins[n.id] = { x, y }; },
|
|
335
|
+
end: () => { this.#persist(); this.render(); },
|
|
336
|
+
});
|
|
337
|
+
const act = () => (n.kind === 'group' ? this.toggleGroup(n.id) : this.select(this.selected === n.id ? null : n.id));
|
|
338
|
+
card.addEventListener('click', () => { if (!swallowClick(card)) act(); });
|
|
339
|
+
card.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); act(); } });
|
|
340
|
+
return card;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
#findings(f) {
|
|
344
|
+
const box = mk('section', 'mp-findings');
|
|
345
|
+
box.setAttribute('aria-label', 'Findings');
|
|
346
|
+
const h = mk('div', 'mp-row');
|
|
347
|
+
h.append(mk('span', 'lbl', `Findings · ${f.shown.length}`), mk('span', 'sub', `${f.proven} proven by a file · ${f.shown.length - f.proven} inferred`), mk('span', 'row-fill'));
|
|
348
|
+
if (f.hidden) {
|
|
349
|
+
const t = mk('button', 'btn sm ghost', this.showHidden ? `Hide ${f.hidden} hidden by default` : `Show ${f.hidden} hidden by default`);
|
|
350
|
+
t.type = 'button';
|
|
351
|
+
t.title = 'Rules wrong too often to show unasked. Their findings are kept, not deleted.';
|
|
352
|
+
t.addEventListener('click', () => { this.showHidden = !this.showHidden; this.render(); });
|
|
353
|
+
h.append(t);
|
|
354
|
+
}
|
|
355
|
+
h.append(mk('span', 'sub mp-hint', 'Click a row to show it on the map'));
|
|
356
|
+
box.append(h);
|
|
357
|
+
if (!f.shown.length) box.append(mk('p', 'sub', f.total ? 'Every finding of this map is hidden by default.' : 'No finding in this map.'));
|
|
358
|
+
const list = mk('div', 'mp-flist');
|
|
359
|
+
for (const x of f.shown) {
|
|
360
|
+
const row = mk('button', 'mp-frow');
|
|
361
|
+
row.type = 'button';
|
|
362
|
+
const dot = mk('span', 'mp-dot'); dot.dataset.proven = String(x.proven);
|
|
363
|
+
row.append(dot, mk('span', 'mp-ftitle', x.title), mk('span', 'mp-fwhere', x.where), mk('span', 'row-fill'), mk('span', 'mp-badge', x.proven ? 'proven' : 'inferred'));
|
|
364
|
+
if (x.hidden) row.title = x.hiddenReason ?? 'Hidden by default';
|
|
365
|
+
row.addEventListener('click', () => { if (x.modules[0]) this.select(x.modules[0], { reveal: true }); });
|
|
366
|
+
list.append(row);
|
|
367
|
+
}
|
|
368
|
+
box.append(list);
|
|
369
|
+
return box;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** The modules as a list: what a phone shows in place of the pane. */
|
|
373
|
+
#modList(all, counts) {
|
|
374
|
+
const rows = all.filter((n) => n.kind !== 'external');
|
|
375
|
+
const items = rows.map((n) => {
|
|
376
|
+
const r = mk('button', 'mp-mrow');
|
|
377
|
+
r.type = 'button';
|
|
378
|
+
r.dataset.id = n.id;
|
|
379
|
+
r.append(mk('span', 'mp-name', n.name ?? n.id), mk('span', 'row-fill'), mk('span', 'sub', BAND_WORD[n.band] ?? ''));
|
|
380
|
+
if (counts[n.id]) r.append(mk('span', 'mp-fb', String(counts[n.id])));
|
|
381
|
+
r.addEventListener('click', () => this.select(n.id));
|
|
382
|
+
return r;
|
|
383
|
+
});
|
|
384
|
+
this.list.replaceChildren(mk('span', 'lbl', `Modules · ${rows.length}`), ...items);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
#inspector(node, doc, edges, findings) {
|
|
388
|
+
const parts = [];
|
|
389
|
+
if (!node) {
|
|
390
|
+
// The project's overview, when /crew-map wrote one: what it says, and the lines it rests on.
|
|
391
|
+
const ov = doc?.overview;
|
|
392
|
+
if (typeof ov?.text === 'string' && ov.text.trim()) {
|
|
393
|
+
parts.push(mk('span', 'lbl', 'Overview'), mk('p', 'mp-overview', ov.text));
|
|
394
|
+
if (Array.isArray(ov.cites) && ov.cites.length) parts.push(mk('p', 'sub mp-cites', `From ${ov.cites.filter((c) => typeof c === 'string').join(', ')}`));
|
|
395
|
+
}
|
|
396
|
+
parts.push(mk('p', 'sub', 'Select a module to see what it connects to and what was found in it. Esc lets it go.'));
|
|
397
|
+
} else {
|
|
398
|
+
const v = inspectOf(node, doc, edges, findings);
|
|
399
|
+
const top = mk('div', 'mp-row');
|
|
400
|
+
const close = mk('button', 'btn icon sm ghost', '×');
|
|
401
|
+
close.type = 'button'; close.setAttribute('aria-label', 'Close inspector');
|
|
402
|
+
close.addEventListener('click', () => this.select(null));
|
|
403
|
+
top.append(mk('span', 'mp-name', v.title), mk('span', 'row-fill'), close);
|
|
404
|
+
parts.push(top);
|
|
405
|
+
if (v.path) parts.push(mk('code', 'sub', v.path));
|
|
406
|
+
if (v.chips.length) { const c = mk('div', 'mp-chips'); for (const t of v.chips) c.append(mk('span', 'mp-chip', t)); parts.push(c); }
|
|
407
|
+
const tabs = mk('nav', 'mp-tabs');
|
|
408
|
+
for (const [k, label] of [['overview', 'Overview'], ['connections', `Connections ${v.connections.out.length + v.connections.in.length}`], ['findings', `Findings ${v.findings.length}`]]) {
|
|
409
|
+
const t = mk('button', 'mp-tab', label);
|
|
410
|
+
t.type = 'button';
|
|
411
|
+
t.setAttribute('aria-selected', String(this.tab === k));
|
|
412
|
+
t.addEventListener('click', () => { this.tab = k; this.render(); });
|
|
413
|
+
tabs.append(t);
|
|
414
|
+
}
|
|
415
|
+
parts.push(tabs);
|
|
416
|
+
const sec = mk('div', 'mp-isec');
|
|
417
|
+
if (this.tab === 'overview') {
|
|
418
|
+
sec.append(mk('span', 'lbl', 'What it is for'), mk('p', v.narrative ? null : 'sub', v.narrative ?? v.narrativeNote));
|
|
419
|
+
if (v.narrative && v.narrativeCites.length) sec.append(mk('p', 'sub mp-cites', `From ${v.narrativeCites.join(', ')}`));
|
|
420
|
+
sec.append(mk('span', 'lbl', 'Connects to'));
|
|
421
|
+
if (!v.connectsTo.length) sec.append(mk('p', 'sub', 'Nothing on this map.'));
|
|
422
|
+
for (const c of v.connectsTo) sec.append(this.#conn(c));
|
|
423
|
+
if (v.evidence.length) {
|
|
424
|
+
sec.append(mk('span', 'lbl', `Evidence · ${v.evidenceTotal}`));
|
|
425
|
+
for (const e of v.evidence) sec.append(this.#cite(e));
|
|
426
|
+
if (v.evidenceTotal > v.evidence.length) sec.append(mk('p', 'sub', `${v.evidenceTotal - v.evidence.length} more in the map file`));
|
|
427
|
+
}
|
|
428
|
+
} else if (this.tab === 'connections') {
|
|
429
|
+
if (!v.byKind.length) sec.append(mk('p', 'sub', 'Nothing on this map.'));
|
|
430
|
+
for (const [kind, { out, in: into }] of v.byKind) {
|
|
431
|
+
sec.append(mk('span', 'lbl', `${kind} · ${out.length + into.length}`));
|
|
432
|
+
for (const c of out) sec.append(this.#conn(c, 'out'));
|
|
433
|
+
for (const c of into) sec.append(this.#conn(c, 'in'));
|
|
434
|
+
}
|
|
435
|
+
} else {
|
|
436
|
+
if (!v.findings.length) sec.append(mk('p', 'sub', 'No finding shown for this node.'));
|
|
437
|
+
for (const f of v.findings) {
|
|
438
|
+
const card = mk('div', 'mp-fcard');
|
|
439
|
+
card.dataset.proven = String(f.proven);
|
|
440
|
+
const r = mk('div', 'mp-row');
|
|
441
|
+
r.append(mk('span', 'mp-ftitle', f.title), mk('span', 'row-fill'), mk('span', 'mp-badge', f.proven ? 'proven' : 'inferred'));
|
|
442
|
+
const b = mk('button', 'btn sm', 'Copy as task');
|
|
443
|
+
b.type = 'button';
|
|
444
|
+
b.title = 'Copies the finding as a task to paste into a session. Nothing is run.';
|
|
445
|
+
b.addEventListener('click', () => this.hooks.onCopy?.(taskOf(f), 'task'));
|
|
446
|
+
card.append(r);
|
|
447
|
+
if (f.where) card.append(this.#cite(f.where));
|
|
448
|
+
card.append(b);
|
|
449
|
+
sec.append(card);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
parts.push(sec);
|
|
453
|
+
}
|
|
454
|
+
const nm = mk('div', 'mp-notmeasured');
|
|
455
|
+
nm.append(mk('span', 'lbl', 'Not measured'));
|
|
456
|
+
if (!doc.notMeasured.length) nm.append(mk('p', 'sub', 'Nothing: every file of the project was read.'));
|
|
457
|
+
for (const x of doc.notMeasured) nm.append(mk('p', 'sub', `${x.what} — ${x.reason}`));
|
|
458
|
+
parts.push(nm);
|
|
459
|
+
this.side.replaceChildren(...parts);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/** A file:line the viewer can copy. */
|
|
463
|
+
#cite(text) {
|
|
464
|
+
const b = mk('button', 'mp-ev mp-cite', text);
|
|
465
|
+
b.type = 'button';
|
|
466
|
+
b.title = 'Copy the path';
|
|
467
|
+
b.addEventListener('click', () => this.hooks.onCopy?.(text, 'path'));
|
|
468
|
+
return b;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
#conn(c, dir = null) {
|
|
472
|
+
const row = mk('div', 'mp-conn');
|
|
473
|
+
const r = mk('div', 'mp-row');
|
|
474
|
+
r.append(mk('span', 'mp-kind', dir === 'in' ? `← ${c.kind}` : c.kind), mk('span', 'mp-name', c.to), mk('span', 'row-fill'));
|
|
475
|
+
if (c.host) r.append(mk('span', 'sub', c.host));
|
|
476
|
+
row.append(r);
|
|
477
|
+
if (c.evidence) {
|
|
478
|
+
const ev = this.#cite(c.evidence);
|
|
479
|
+
if (c.line !== 'evidence') ev.title = `Copy the path · ${c.line === 'model' ? 'proposed by the model' : 'inferred'}`;
|
|
480
|
+
row.append(ev);
|
|
481
|
+
if (c.line !== 'evidence') row.append(mk('span', 'sub', c.line === 'model' ? 'proposed by the model' : 'inferred'));
|
|
482
|
+
}
|
|
483
|
+
return row;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
#persist() {
|
|
487
|
+
const cwd = this.data?.cwd;
|
|
488
|
+
if (!cwd) return;
|
|
489
|
+
try {
|
|
490
|
+
localStorage.setItem(LAYOUT_KEY + cwd, JSON.stringify({ pins: this.pins, folded: [...this.folded] }));
|
|
491
|
+
} catch { /* kept for this page view only */ }
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
#restore(cwd) {
|
|
495
|
+
this.pins = {}; this.folded = new Set();
|
|
496
|
+
if (!cwd) return;
|
|
497
|
+
try {
|
|
498
|
+
const saved = JSON.parse(localStorage.getItem(LAYOUT_KEY + cwd) ?? 'null');
|
|
499
|
+
for (const [id, p] of Object.entries(saved?.pins ?? {})) if (Number.isFinite(p?.x) && Number.isFinite(p?.y)) this.pins[id] = { x: p.x, y: p.y };
|
|
500
|
+
for (const id of saved?.folded ?? []) if (typeof id === 'string') this.folded.add(id);
|
|
501
|
+
} catch { /* start from the map's own layout */ }
|
|
502
|
+
}
|
|
503
|
+
}
|
|
@@ -52,15 +52,25 @@ function stepOf(r, again) {
|
|
|
52
52
|
* How the work went, as steps: the earlier calls of the same card, then this one. `row` is Crewforth's line for
|
|
53
53
|
* the agent with `before` (see server/lib/model-outcomes.js), or null when it has none: it has not stopped, or it
|
|
54
54
|
* is not a crew agent.
|
|
55
|
-
* @returns { risk, steps: [{ text, verify, tone, escalated }], note }
|
|
55
|
+
* @returns { risk, riskWhy, steps: [{ text, verify, tone, escalated }], note }
|
|
56
56
|
*/
|
|
57
57
|
export function workSteps(row) {
|
|
58
|
-
if (!row) return { risk: null, steps: [], note: 'Not recorded yet: the line is written when the agent stops.' };
|
|
58
|
+
if (!row) return { risk: null, riskWhy: null, steps: [], note: 'Not recorded yet: the line is written when the agent stops.' };
|
|
59
59
|
const steps = [...(row.before ?? []).map((r) => stepOf(r, false)), stepOf(row, Boolean(row.escalatedFrom))];
|
|
60
60
|
if (row.escalatedFrom && !(row.before ?? []).length) {
|
|
61
61
|
steps.unshift({ text: `An earlier call on ${tierWord(row.escalatedFrom)} did not pass; its line is not in the record`, verify: null, tone: null, escalated: false });
|
|
62
62
|
}
|
|
63
|
-
|
|
63
|
+
// The reason is the gate's own sentence, kept as it wrote it; a line written before the gate kept one has none.
|
|
64
|
+
return { risk: row.risk ?? null, riskWhy: row.risk === 'critical' ? row.riskWhy ?? null : null, steps, note: row.escalatedFrom ? 'A re-run with the same or a lower model is refused by the gate.' : null };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* What is said about an agent the model gate does not look at: one whose type is not crew-* (hooks/guard-agent-model.sh
|
|
69
|
+
* leaves it alone), so there is no card to read and no line in the record. Null for a crew agent or an unknown type.
|
|
70
|
+
*/
|
|
71
|
+
export function outsideRouting(agentType) {
|
|
72
|
+
if (typeof agentType !== 'string' || !agentType || agentType.startsWith('crew-')) return null;
|
|
73
|
+
return 'Outside model routing: not a crew agent, so the gate chose nothing for it and Crewforth kept no record of it. It ran on the model its call asked for, or else the session\u2019s.';
|
|
64
74
|
}
|
|
65
75
|
|
|
66
76
|
/**
|
|
@@ -85,17 +95,31 @@ export function floorWords(floor) {
|
|
|
85
95
|
* ("\u2265$0.75 \u00b7 2/7"), because a sum of two calls must not be read as the cost of seven. None: a dash.
|
|
86
96
|
*/
|
|
87
97
|
export function costCell(cost, runs) {
|
|
88
|
-
if (!cost) return { cost: '\u2026', costNote: 'Reading what this class cost\u2026', partial: false };
|
|
89
|
-
if (!cost.costed) return { cost: '\u2014', costNote: 'No agent of this class has a transcript on this machine that could be priced', partial: false };
|
|
98
|
+
if (!cost) return { cost: '\u2026', costNote: 'Reading what this class cost\u2026', partial: false, advisor: null };
|
|
99
|
+
if (!cost.costed) return { cost: '\u2014', costNote: 'No agent of this class has a transcript on this machine that could be priced', partial: false, advisor: null };
|
|
90
100
|
const usd = fmtCost(cost.cost);
|
|
91
|
-
|
|
101
|
+
const advisor = advisorWords(cost);
|
|
102
|
+
const tail = advisor ? `\n${advisor}` : '';
|
|
103
|
+
if (cost.costed >= runs) return { cost: usd, costNote: `All ${runs} ${runs === 1 ? 'task' : 'tasks'} priced, at API list price. An estimate: what a subscription is charged is not known here.${tail}`, partial: false, advisor };
|
|
92
104
|
return {
|
|
93
105
|
cost: `\u2265${usd.replace(/^~/, '')} \u00b7 ${cost.costed}/${runs}`,
|
|
94
|
-
costNote: `At least this much: ${cost.costed} of ${runs} tasks could be priced; the others have no transcript on this machine. At API list price
|
|
106
|
+
costNote: `At least this much: ${cost.costed} of ${runs} tasks could be priced; the others have no transcript on this machine. At API list price.${tail}`,
|
|
95
107
|
partial: true,
|
|
108
|
+
advisor,
|
|
96
109
|
};
|
|
97
110
|
}
|
|
98
111
|
|
|
112
|
+
/**
|
|
113
|
+
* What of a class's cost was the advisor (a second model its agents consulted mid-task), or null when they consulted
|
|
114
|
+
* none: no call is no tag, not a zero. Its share is already in the class's cost; this only says how much it was.
|
|
115
|
+
*/
|
|
116
|
+
export function advisorWords(cost) {
|
|
117
|
+
const n = cost?.advisorCalls ?? 0;
|
|
118
|
+
if (!(n > 0)) return null;
|
|
119
|
+
const share = typeof cost.advisorCost === 'number' && cost.advisorCost > 0 ? `${fmtCost(cost.advisorCost)} of it` : 'its cost not priced';
|
|
120
|
+
return `Advisor: ${n} ${n === 1 ? 'call' : 'calls'}, ${share}. A second model the agents consulted, at that model's rates; included above.`;
|
|
121
|
+
}
|
|
122
|
+
|
|
99
123
|
/** One class as the view's row: who and what kind of change, how risky, what ran it, and how it went. */
|
|
100
124
|
export function classRow(c, cost = null) {
|
|
101
125
|
const models = Object.entries(c.models ?? {}).sort((a, b) => b[1].runs - a[1].runs).map(([t, m]) => `${tierWord(t)} ${m.runs}`).join(' \u00b7 ');
|
|
@@ -146,6 +170,36 @@ export function suggestionText(s) {
|
|
|
146
170
|
};
|
|
147
171
|
}
|
|
148
172
|
|
|
173
|
+
/**
|
|
174
|
+
* Normal work that ran above the table's model, in words. The gate holds a floor, not a ceiling, so this is said
|
|
175
|
+
* and nothing is blocked. That the table's model would have done is said only from the record (`a.passed`); the
|
|
176
|
+
* cost beside it is the same tokens at that model's price, which is not what that model would have spent.
|
|
177
|
+
* @param a one class of server/lib/model-outcomes.js `aboveTableOf`
|
|
178
|
+
* @param cost { cost, atNormal, costed, runs } for it, or null while costs have not been read
|
|
179
|
+
*/
|
|
180
|
+
export function aboveText(a, cost = null) {
|
|
181
|
+
const to = tierWord(a.to);
|
|
182
|
+
const ran = Object.entries(a.models ?? {}).sort((x, y) => y[1] - x[1]).map(([t, n]) => `${n} on ${tierWord(t)}`).join(', ');
|
|
183
|
+
let costs = null;
|
|
184
|
+
if (cost?.costed) {
|
|
185
|
+
const some = cost.costed < cost.runs;
|
|
186
|
+
const usd = (v) => (some ? `\u2265${fmtCost(v).replace(/^~/, '')}` : fmtCost(v));
|
|
187
|
+
costs = `${usd(cost.cost)} as run; the same tokens at ${to}'s price ${usd(cost.atNormal)}${some ? ` (${cost.costed} of ${cost.runs} priced)` : ''}. API list price; another model would not spend the same tokens.`;
|
|
188
|
+
}
|
|
189
|
+
return {
|
|
190
|
+
title: `${roleName(a.agent)} \u00b7 ${a.change}`,
|
|
191
|
+
says: `normal risk, ${plural(a.runs, 'task')} above ${to}: ${ran}.`,
|
|
192
|
+
verdict: a.passed
|
|
193
|
+
? `${to} is enough for this class: its latest ${a.passed} verified calls on ${to} all passed the first time.`
|
|
194
|
+
: `Whether ${to} would have done is not measured: this class does not have ${a.need ?? 'enough'} verified calls on ${to} that all passed the first time.`,
|
|
195
|
+
measured: Boolean(a.passed),
|
|
196
|
+
costs,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export const ABOVE_HEAD = 'Above the table \u00b7 nothing is blocked';
|
|
201
|
+
export const ABOVE_RULE = 'The gate holds a floor, not a ceiling: which model runs normal work is yours to choose.';
|
|
202
|
+
|
|
149
203
|
/** What the view says when there is nothing to list. */
|
|
150
204
|
export function emptyNote(data) {
|
|
151
205
|
if (!data) return 'Reading the record…';
|
package/kit/studio/web/models.js
CHANGED
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
// A class is an agent, a kind of change and a risk. Each line says what ran it, how many tasks, how many of the
|
|
4
4
|
// verified ones passed the first time, how many were repeated one model up, and what the agents' tokens come to.
|
|
5
5
|
// Under the table: the holds the record could let go of. They are shown, not acted on: the one button copies
|
|
6
|
-
// Crewforth's command for it and lowers nothing.
|
|
7
|
-
|
|
6
|
+
// Crewforth's command for it and lowers nothing. Then the normal work that ran above the table's model: said, with
|
|
7
|
+
// the record's evidence where there is some, and not acted on either.
|
|
8
|
+
import { classRow, suggestionText, aboveText, emptyNote, totalsLine, costLine, SOURCE, LOWER_RULE, ABOVE_HEAD, ABOVE_RULE } from './models-plan.js';
|
|
8
9
|
|
|
9
10
|
function mk(tag, cls, text) {
|
|
10
11
|
const n = document.createElement(tag);
|
|
@@ -26,7 +27,7 @@ export class Models {
|
|
|
26
27
|
}
|
|
27
28
|
|
|
28
29
|
setData(data) { this.data = data; this.render(); }
|
|
29
|
-
/** What the classes cost, read apart and less often: { measured, costs: { classKey: { cost, costed, runs } } }. */
|
|
30
|
+
/** What the classes cost, read apart and less often: { measured, costs: { classKey: { cost, costed, runs } }, above }. */
|
|
30
31
|
setCosts(costs) { this.costs = costs; this.render(); }
|
|
31
32
|
|
|
32
33
|
render() {
|
|
@@ -88,6 +89,7 @@ export class Models {
|
|
|
88
89
|
const cost = mk('td', 'mv-num', r.cost);
|
|
89
90
|
cost.title = r.costNote;
|
|
90
91
|
if (r.partial) cost.dataset.partial = 'true';
|
|
92
|
+
if (r.advisor) { const t = mk('span', 'mv-tag', 'advisor'); t.dataset.kind = 'advisor'; t.title = r.advisor; cost.append(t); }
|
|
91
93
|
tr.append(who, risk, model, mk('td', 'mv-num', r.tasks), first, mk('td', 'mv-num', r.escalated), cost);
|
|
92
94
|
tbody.append(tr);
|
|
93
95
|
}
|
|
@@ -115,6 +117,20 @@ export class Models {
|
|
|
115
117
|
}
|
|
116
118
|
pane.append(box);
|
|
117
119
|
}
|
|
120
|
+
if (d.above?.length) {
|
|
121
|
+
const box = mk('section', 'mv-above');
|
|
122
|
+
box.append(mk('h3', 'mv-suggest-h', ABOVE_HEAD));
|
|
123
|
+
for (const a of d.above) {
|
|
124
|
+
const t = aboveText(a, this.costs?.measured ? (this.costs.above?.[a.key] ?? null) : null);
|
|
125
|
+
box.append(mk('p', 'mv-suggest-text', `${t.title} ${t.says}`));
|
|
126
|
+
const v = mk('p', 'mv-above-verdict', t.verdict);
|
|
127
|
+
v.dataset.measured = String(t.measured);
|
|
128
|
+
box.append(v);
|
|
129
|
+
if (t.costs) box.append(mk('p', 'sub', t.costs));
|
|
130
|
+
}
|
|
131
|
+
box.append(mk('p', 'sub', ABOVE_RULE));
|
|
132
|
+
pane.append(box);
|
|
133
|
+
}
|
|
118
134
|
for (const note of raisedNotes) {
|
|
119
135
|
const row = mk('div', 'mv-raised');
|
|
120
136
|
const tag = mk('span', 'mv-tag', note.tag);
|