@bongos/core 1.19.544 → 1.19.546
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/.bongos-core.json +87 -42
- package/docs/adr/0227-a-task-network-map-is-refused-the-goal-graph-is-the-map.md +115 -0
- package/docs/copy-inventory.md +42 -18
- package/docs/copy-registry.json +271 -25
- package/docs/module-api-changelog.md +4 -0
- package/docs/research/task-network-map/real-a-every-task.png +0 -0
- package/docs/research/task-network-map/real-b-dependency-forest.png +0 -0
- package/docs/research/task-network-map/real-c-goals.png +0 -0
- package/docs/research/task-network-map/real-d-joined-by-goal.png +0 -0
- package/modules/hall-ui/public/hall-kit.js +76 -41
- package/modules/hall-ui/public/profile-charts.js +8 -2
- package/modules/hall-ui/public/shell.js +30 -1
- package/modules/hall-ui/public/studio.css +268 -0
- package/modules/hall-ui/public/studio.html +74 -0
- package/modules/hall-ui/public/studio.js +213 -0
- package/modules/hall-ui/public/studio.states.json +54 -0
- package/modules/hall-ui/public/style.css +9 -0
- package/modules/ui-design/skills/brandkit/SKILL.md +5 -1
- package/modules/ui-design/skills/design-taste-frontend/SKILL.md +5 -1
- package/modules/ui-design/skills/design-taste-frontend-v1/SKILL.md +5 -1
- package/modules/ui-design/skills/gpt-taste/SKILL.md +5 -1
- package/modules/ui-design/skills/high-end-visual-design/SKILL.md +5 -1
- package/modules/ui-design/skills/image-to-code/SKILL.md +5 -1
- package/modules/ui-design/skills/imagegen-frontend-mobile/SKILL.md +5 -1
- package/modules/ui-design/skills/imagegen-frontend-web/SKILL.md +5 -1
- package/modules/ui-design/skills/impeccable/SKILL.md +5 -1
- package/modules/ui-design/skills/industrial-brutalist-ui/SKILL.md +5 -1
- package/modules/ui-design/skills/minimalist-ui/SKILL.md +5 -1
- package/modules/ui-design/skills/redesign-existing-projects/SKILL.md +5 -1
- package/modules/ui-design/skills/stitch-design-taste/SKILL.md +5 -1
- package/modules/ui-design/skills/style/SKILL.md +5 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/fitness.js +10 -3
- package/scripts/gds/skill-lint.js +43 -9
- package/scripts/gds/spike-task-map.js +178 -9
- package/src/bongos/serve-internal.js +1 -1
- package/src/module-api.js +1 -1
- package/tests/hall_kit_charts.mjs +59 -0
- package/tests/hall_nav.mjs +6 -1
- package/tests/hall_page_gate_map.mjs +5 -0
- package/tests/hall_studio_world.mjs +143 -0
- package/tests/skill_lint.mjs +51 -2
- package/tests/spike_task_map.mjs +0 -0
- package/tests/ui_design_skills.mjs +12 -2
|
@@ -139,24 +139,160 @@ function buildSynthetic({ nodes, goals, density, seed = 1003282 }) {
|
|
|
139
139
|
return tasks;
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
+
// depIdsOf — the dependency ids on one row of a real dump, whatever the API called
|
|
143
|
+
// them. This is load-bearing and was WRONG until task 1003477: `GET /tasks?include=deps`
|
|
144
|
+
// answers with `blocked_by` (an array of {id,title,status,goal_id,satisfied} — see
|
|
145
|
+
// db.attachTaskDepSummaries), and this loader only ever looked for `dependencies`/`deps`.
|
|
146
|
+
// A real dump therefore parsed as a corpus with ZERO edges, and every --from number
|
|
147
|
+
// would have described an empty graph while looking perfectly plausible — the exact
|
|
148
|
+
// failure a spike must not have, because nothing about the output says "no edges here
|
|
149
|
+
// because the field was misnamed". All three spellings are accepted now, and each may
|
|
150
|
+
// hold bare ids or objects carrying one.
|
|
151
|
+
function depIdsOf(row) {
|
|
152
|
+
// First NON-EMPTY array wins, not the first field present: a dump carrying both an
|
|
153
|
+
// empty `blocked_by` and a populated `dependencies` would otherwise read as edgeless,
|
|
154
|
+
// which is the same silent zero this function was written to stop.
|
|
155
|
+
const raw = [row.blocked_by, row.dependencies, row.deps]
|
|
156
|
+
.find((v) => Array.isArray(v) && v.length > 0) || [];
|
|
157
|
+
return raw
|
|
158
|
+
.map((d) => Number(d && typeof d === 'object' ? d.id : d))
|
|
159
|
+
.filter(Number.isFinite);
|
|
160
|
+
}
|
|
161
|
+
|
|
142
162
|
// loadReal — the same shape from a real `GET /tasks?include=deps` dump, so the next
|
|
143
163
|
// session with DB access can replace every synthetic number in the verdict with a
|
|
144
164
|
// measured one by re-running with --from.
|
|
165
|
+
//
|
|
166
|
+
// GOALS: a task with no goal does NOT share a bucket with the other ungoaled tasks —
|
|
167
|
+
// "no goal" is not a goal they have in common, and pooling them would silently turn
|
|
168
|
+
// every dependency between two ungoaled tasks into a WITHIN-goal edge, understating
|
|
169
|
+
// the one number this loader exists to produce. Each gets its own bucket instead;
|
|
170
|
+
// `goalId: null` marks them so census() can report them apart from real goals.
|
|
145
171
|
function loadReal(file) {
|
|
146
172
|
const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
147
173
|
const rows = Array.isArray(raw) ? raw : (raw.tasks || raw.rows || []);
|
|
148
174
|
const goalIndex = new Map();
|
|
175
|
+
let nextBucket = 0;
|
|
149
176
|
return rows.map((r) => {
|
|
150
|
-
const
|
|
151
|
-
|
|
177
|
+
const gid = r.goal_id == null || r.goal_id === '' ? null : String(r.goal_id);
|
|
178
|
+
let bucket;
|
|
179
|
+
if (gid === null) {
|
|
180
|
+
bucket = nextBucket++; // ungoaled: a bucket of one
|
|
181
|
+
} else {
|
|
182
|
+
if (!goalIndex.has(gid)) goalIndex.set(gid, nextBucket++);
|
|
183
|
+
bucket = goalIndex.get(gid);
|
|
184
|
+
}
|
|
152
185
|
return {
|
|
153
186
|
id: Number(r.id),
|
|
154
|
-
goal:
|
|
155
|
-
|
|
187
|
+
goal: bucket,
|
|
188
|
+
goalId: gid,
|
|
189
|
+
deps: depIdsOf(r),
|
|
156
190
|
};
|
|
157
191
|
});
|
|
158
192
|
}
|
|
159
193
|
|
|
194
|
+
// census — THE TWO NUMBERS task 1003477 exists to measure, plus the three that decide
|
|
195
|
+
// what they mean. ADR 0227 deferred items 3 (the goal graph) and 4 (the dependency
|
|
196
|
+
// forest) on a SYNTHETIC density sweep and said the live corpus must settle them.
|
|
197
|
+
//
|
|
198
|
+
// with_dependency_pct — the share of tasks carrying at least one dependency row.
|
|
199
|
+
// Item 4's question: is there a forest, or is the corpus
|
|
200
|
+
// confetti? Note this is OUT-degree (what a task waits on),
|
|
201
|
+
// not the degree-0 count the table's `isol%` reports — a task
|
|
202
|
+
// that only BLOCKS others declares no row of its own but is
|
|
203
|
+
// still drawn, so the two numbers differ and mean different
|
|
204
|
+
// things.
|
|
205
|
+
// cross_goal_pct — the share of dependency edges whose two ends sit in
|
|
206
|
+
// different goals. Item 3's question: does the goal graph
|
|
207
|
+
// have EDGES, or is it forty disconnected dots?
|
|
208
|
+
//
|
|
209
|
+
// And what turns those into a verdict: how many goals are left with no cross-goal edge
|
|
210
|
+
// at all (isolated dots a node-link layout has nothing to say about), how many separate
|
|
211
|
+
// components the forest breaks into, and how big the typical one is — a forest of 128
|
|
212
|
+
// four-node trees is a drill-down, not a map, however many edges it contains.
|
|
213
|
+
function census(tasks) {
|
|
214
|
+
const byId = new Map(tasks.map((t) => [t.id, t]));
|
|
215
|
+
const edges = [];
|
|
216
|
+
let withDep = 0;
|
|
217
|
+
for (const t of tasks) {
|
|
218
|
+
const live = t.deps.filter((d) => byId.has(d));
|
|
219
|
+
if (t.deps.length > 0) withDep += 1;
|
|
220
|
+
for (const d of live) edges.push([t.id, d]);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
let crossGoal = 0;
|
|
224
|
+
let touchingUngoaled = 0;
|
|
225
|
+
const goalPairs = new Set();
|
|
226
|
+
const goalsWithEdge = new Set();
|
|
227
|
+
for (const [a, b] of edges) {
|
|
228
|
+
const ga = byId.get(a).goalId;
|
|
229
|
+
const gb = byId.get(b).goalId;
|
|
230
|
+
if (ga === null || gb === null) { touchingUngoaled += 1; continue; }
|
|
231
|
+
if (ga === gb) continue;
|
|
232
|
+
crossGoal += 1;
|
|
233
|
+
goalPairs.add(`${ga}>${gb}`);
|
|
234
|
+
goalsWithEdge.add(ga);
|
|
235
|
+
goalsWithEdge.add(gb);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const realGoals = new Set(tasks.map((t) => t.goalId).filter((g) => g !== null));
|
|
239
|
+
const ungoaled = tasks.filter((t) => t.goalId === null).length;
|
|
240
|
+
|
|
241
|
+
// The forest: only the tasks with an edge in either direction, and how it breaks up.
|
|
242
|
+
const degree = new Map(tasks.map((t) => [t.id, 0]));
|
|
243
|
+
for (const [a, b] of edges) {
|
|
244
|
+
degree.set(a, degree.get(a) + 1);
|
|
245
|
+
degree.set(b, degree.get(b) + 1);
|
|
246
|
+
}
|
|
247
|
+
const connected = tasks.filter((t) => degree.get(t.id) > 0).map((t) => t.id);
|
|
248
|
+
const comp = components(connected, edges);
|
|
249
|
+
const sizes = componentSizes(connected, edges);
|
|
250
|
+
const median = sizes.length ? sizes[Math.floor(sizes.length / 2)] : 0;
|
|
251
|
+
|
|
252
|
+
// THE DRILL-DOWN, which is the shape item 4 is actually scoped as ("queried when a
|
|
253
|
+
// goal is opened") — not the whole-corpus forest. Per goal: the tasks with an edge
|
|
254
|
+
// whose OTHER end sits in the same goal. Its WORST case is the number that decides
|
|
255
|
+
// the item, because a drill-down has to survive its biggest goal, not its median one.
|
|
256
|
+
const perGoal = new Map();
|
|
257
|
+
for (const [a, b] of edges) {
|
|
258
|
+
const ga = byId.get(a).goalId;
|
|
259
|
+
if (ga === null || ga !== byId.get(b).goalId) continue;
|
|
260
|
+
if (!perGoal.has(ga)) perGoal.set(ga, new Set());
|
|
261
|
+
perGoal.get(ga).add(a);
|
|
262
|
+
perGoal.get(ga).add(b);
|
|
263
|
+
}
|
|
264
|
+
const perGoalSizes = [...perGoal.values()].map((v) => v.size).sort((x, y) => y - x);
|
|
265
|
+
const perGoalMax = perGoalSizes[0] || 0;
|
|
266
|
+
const perGoalMedian = perGoalSizes.length ? perGoalSizes[Math.floor(perGoalSizes.length / 2)] : 0;
|
|
267
|
+
|
|
268
|
+
const pct = (n, d) => (d ? Number(((n / d) * 100).toFixed(2)) : 0);
|
|
269
|
+
return {
|
|
270
|
+
tasks: tasks.length,
|
|
271
|
+
edges: edges.length,
|
|
272
|
+
with_dependency: withDep,
|
|
273
|
+
with_dependency_pct: pct(withDep, tasks.length),
|
|
274
|
+
connected: connected.length,
|
|
275
|
+
connected_pct: pct(connected.length, tasks.length),
|
|
276
|
+
cross_goal: crossGoal,
|
|
277
|
+
cross_goal_pct: pct(crossGoal, edges.length),
|
|
278
|
+
edges_touching_ungoaled: touchingUngoaled,
|
|
279
|
+
goals: realGoals.size,
|
|
280
|
+
ungoaled_tasks: ungoaled,
|
|
281
|
+
goal_pairs: goalPairs.size,
|
|
282
|
+
goals_with_cross_goal_edge: goalsWithEdge.size,
|
|
283
|
+
goals_isolated: realGoals.size - goalsWithEdge.size,
|
|
284
|
+
goals_isolated_pct: pct(realGoals.size - goalsWithEdge.size, realGoals.size),
|
|
285
|
+
forest_components: comp.count,
|
|
286
|
+
forest_largest: comp.largest,
|
|
287
|
+
forest_median_component: median,
|
|
288
|
+
per_goal_forests: perGoalSizes.length,
|
|
289
|
+
per_goal_largest: perGoalMax,
|
|
290
|
+
per_goal_median: perGoalMedian,
|
|
291
|
+
// The drill-down clears the bar only if its BIGGEST goal still fits labels.
|
|
292
|
+
per_goal_largest_labels: inkBudget(perGoalMax || 1).fits_with_label,
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
|
|
160
296
|
// ---------------------------------------------------------------------------
|
|
161
297
|
// graph views — the literal ask, and the two shapes it could become
|
|
162
298
|
// ---------------------------------------------------------------------------
|
|
@@ -223,7 +359,11 @@ function viewsOf(tasks) {
|
|
|
223
359
|
|
|
224
360
|
// weakly-connected components, ignoring edge direction — a reader does not perceive
|
|
225
361
|
// arrow direction when deciding "are these two blobs the same thing".
|
|
226
|
-
|
|
362
|
+
// componentSizes — the same union-find, returning the raw sizes largest-first. Split
|
|
363
|
+
// out because "how many components" and "how big is the typical one" are different
|
|
364
|
+
// questions: 128 components says a view is fragmented, but only the MEDIAN size says
|
|
365
|
+
// whether each fragment is a readable little tree or a hairball of its own (census()).
|
|
366
|
+
function componentSizes(nodes, edges) {
|
|
227
367
|
const idx = new Map(nodes.map((n, i) => [n, i]));
|
|
228
368
|
const parent = nodes.map((_, i) => i);
|
|
229
369
|
const find = (x) => { while (parent[x] !== x) { parent[x] = parent[parent[x]]; x = parent[x]; } return x; };
|
|
@@ -238,7 +378,11 @@ function components(nodes, edges) {
|
|
|
238
378
|
const r = find(i);
|
|
239
379
|
sizes.set(r, (sizes.get(r) || 0) + 1);
|
|
240
380
|
}
|
|
241
|
-
|
|
381
|
+
return [...sizes.values()].sort((a, b) => b - a);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function components(nodes, edges) {
|
|
385
|
+
const all = componentSizes(nodes, edges);
|
|
242
386
|
return { count: all.length, largest: all[0] || 0, singletons: all.filter((s) => s === 1).length };
|
|
243
387
|
}
|
|
244
388
|
|
|
@@ -658,11 +802,34 @@ function measure(label, view, { render: doRender, file, radius = 3 } = {}) {
|
|
|
658
802
|
};
|
|
659
803
|
}
|
|
660
804
|
|
|
805
|
+
// printCensus — the two gating numbers first and in words, because a reader who
|
|
806
|
+
// stops after the first screen must still leave with the answer ADR 0227 asked for.
|
|
807
|
+
function printCensus(c) {
|
|
808
|
+
const cg = c.goals_with_cross_goal_edge;
|
|
809
|
+
console.log('live corpus census — the numbers ADR 0227 items 3 and 4 wait on');
|
|
810
|
+
console.log(` corpus ${c.tasks} tasks, ${c.goals} goals`
|
|
811
|
+
+ (c.ungoaled_tasks ? `, ${c.ungoaled_tasks} task(s) with no goal` : ''));
|
|
812
|
+
console.log(` Q1 tasks with a dependency ${c.with_dependency} (${c.with_dependency_pct}%) → item 4, the dependency forest`);
|
|
813
|
+
console.log(` Q2 cross-goal edges ${c.cross_goal} of ${c.edges} (${c.cross_goal_pct}%) → item 3, the goal graph`);
|
|
814
|
+
if (c.edges_touching_ungoaled) {
|
|
815
|
+
console.log(` (${c.edges_touching_ungoaled} edge(s) touch a task with no goal and are counted as neither)`);
|
|
816
|
+
}
|
|
817
|
+
console.log(` goal graph shape ${c.goal_pairs} distinct goal pairs; ${cg} goal(s) carry an edge, `
|
|
818
|
+
+ `${c.goals_isolated} isolated (${c.goals_isolated_pct}%)`);
|
|
819
|
+
console.log(` forest shape ${c.connected} tasks with any edge (${c.connected_pct}%) in `
|
|
820
|
+
+ `${c.forest_components} components; largest ${c.forest_largest}, median ${c.forest_median_component}`);
|
|
821
|
+
console.log(` per-goal drill-down ${c.per_goal_forests} goal(s) have one; largest ${c.per_goal_largest}, `
|
|
822
|
+
+ `median ${c.per_goal_median} — worst case ${c.per_goal_largest_labels ? 'still carries labels' : 'CANNOT carry labels'}`);
|
|
823
|
+
console.log('');
|
|
824
|
+
}
|
|
825
|
+
|
|
661
826
|
function main() {
|
|
662
827
|
const results = [];
|
|
828
|
+
let realCensus = null;
|
|
663
829
|
|
|
664
830
|
if (FROM) {
|
|
665
831
|
const tasks = loadReal(path.resolve(REPO_ROOT, FROM));
|
|
832
|
+
realCensus = census(tasks);
|
|
666
833
|
const v = viewsOf(tasks);
|
|
667
834
|
results.push(measure('real', v.A, { render: RENDER, file: path.join(OUT_DIR, 'real-a-every-task.png') }));
|
|
668
835
|
results.push(measure('real', v.B, { render: RENDER, file: path.join(OUT_DIR, 'real-b-dependency-forest.png') }));
|
|
@@ -688,13 +855,14 @@ function main() {
|
|
|
688
855
|
}
|
|
689
856
|
|
|
690
857
|
if (AS_JSON) {
|
|
691
|
-
process.stdout.write(`${JSON.stringify({ canvas: [CANVAS_W, CANVAS_H], nodes: N_TASKS, goals: N_GOALS, results }, null, 2)}\n`);
|
|
858
|
+
process.stdout.write(`${JSON.stringify({ canvas: [CANVAS_W, CANVAS_H], nodes: N_TASKS, goals: N_GOALS, census: realCensus, results }, null, 2)}\n`);
|
|
692
859
|
return;
|
|
693
860
|
}
|
|
694
861
|
|
|
695
862
|
const pad = (s, n) => String(s).padEnd(n);
|
|
696
863
|
const rpad = (s, n) => String(s).padStart(n);
|
|
697
|
-
|
|
864
|
+
if (realCensus) printCensus(realCensus);
|
|
865
|
+
console.log(`task-network-map spike — ${realCensus ? realCensus.tasks : N_TASKS} tasks over ${realCensus ? realCensus.goals : N_GOALS} goals, drawn at ${CANVAS_W}×${CANVAS_H}`);
|
|
698
866
|
console.log(`ink floor: a legible dot needs ${MIN_DOT_PITCH}px of pitch; a label needs ${MIN_LABEL_W}×${MIN_LABEL_H}px `
|
|
699
867
|
+ `(${inkBudget(1).labels_that_fit} labels fit on this canvas, total)`);
|
|
700
868
|
console.log('');
|
|
@@ -725,6 +893,7 @@ if (require.main === module) main();
|
|
|
725
893
|
// alpha-blend bug would quietly corrupt the evidence rather than fail loudly.
|
|
726
894
|
// Exported = coverable; see tests/spike_task_map.mjs.
|
|
727
895
|
module.exports = {
|
|
728
|
-
buildSynthetic,
|
|
896
|
+
buildSynthetic, loadReal, depIdsOf, census, viewsOf, components, componentSizes,
|
|
897
|
+
layout, crossingCount, inkBudget, mulberry32,
|
|
729
898
|
crc32, chunk, writePng, canvas, goalColor, render,
|
|
730
899
|
};
|
|
@@ -1033,7 +1033,7 @@ function mountInternalSurfaces(app) {
|
|
|
1033
1033
|
// any-builder and scoped to the caller by the SERVER (GET /help-requests/for-me
|
|
1034
1034
|
// derives crafts from the viewer's own profile), so signed-in is the right floor.
|
|
1035
1035
|
const SIGNEDIN_BUILDERS_PAGE_RE =
|
|
1036
|
-
/^\/builders\/(?:work|pair|settings|atlas|modules|goals|roadmap|copy-desk|ideas|collab|primer(?:\.md)?|diagrams)(?:\.html)?\/?$/;
|
|
1036
|
+
/^\/builders\/(?:work|pair|settings|atlas|modules|goals|roadmap|copy-desk|studio|ideas|collab|primer(?:\.md)?|diagrams)(?:\.html)?\/?$/;
|
|
1037
1037
|
// The diagram image assets under /builders/diagrams/ ride the same tier as the
|
|
1038
1038
|
// page that embeds them.
|
|
1039
1039
|
const SIGNEDIN_BUILDERS_PREFIX_RE = /^\/builders\/diagrams\//;
|
package/src/module-api.js
CHANGED
|
@@ -49,7 +49,7 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
49
49
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
50
50
|
// the entry to that file. Look for a version's history there, not here.
|
|
51
51
|
// ---------------------------------------------------------------------------
|
|
52
|
-
const CORE_VERSION = '1.19.
|
|
52
|
+
const CORE_VERSION = '1.19.546'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
53
53
|
|
|
54
54
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
55
55
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -623,3 +623,62 @@ test('CAL: the grid gets its own horizontal strip — it never widens the docume
|
|
|
623
623
|
const html = kit.calendarGridHtml([{ local_date: '2026-09-03', count: 1 }], { today: '2026-09-03' });
|
|
624
624
|
assert.match(html, /kit-cal__scroll/, 'the wide grid scrolls inside its own strip');
|
|
625
625
|
});
|
|
626
|
+
|
|
627
|
+
// --- task 1003583: the summary and the caption must describe the DRAWN window ------
|
|
628
|
+
|
|
629
|
+
test('CAL: a narrowed window is described as weeks, not as "the last year"', () => {
|
|
630
|
+
// The bug: the default aria summary hardcoded "over the last year" whatever
|
|
631
|
+
// opts.weeks said, so a caller asking for six weeks was handed a label describing
|
|
632
|
+
// fifty-three.
|
|
633
|
+
const html = kit.calendarGridHtml([{ local_date: '2026-09-03', count: 2 }], { today: '2026-09-03', weeks: 6 });
|
|
634
|
+
assert.match(html, /over the last 6 weeks/, 'the aria summary still claims a year');
|
|
635
|
+
assert.doesNotMatch(html, /over the last year/);
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
test('CAL: a one-week window says "week", and the full window still says "the last year"', () => {
|
|
639
|
+
const one = kit.calendarGridHtml([{ local_date: '2026-09-03', count: 1 }], { today: '2026-09-03', weeks: 1 });
|
|
640
|
+
assert.match(one, /over the last 1 week:/, 'singular grammar');
|
|
641
|
+
const full = kit.calendarGridHtml([{ local_date: '2026-09-03', count: 1 }], { today: '2026-09-03', weeks: 53 });
|
|
642
|
+
assert.match(full, /over the last year/, 'the 53-week window is a year and should read like one');
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
test('CAL: the summary counts only the days the grid actually draws', () => {
|
|
646
|
+
// 2026-09-03 is a Thursday, so a 2-week grid starts at Sunday 2026-08-23. A ship on
|
|
647
|
+
// 2026-08-01 is real history the caller supplied, but it is off this picture, and it
|
|
648
|
+
// used to be counted in this picture's summary anyway.
|
|
649
|
+
const days = [
|
|
650
|
+
{ local_date: '2026-08-01', count: 4 }, // before the first column
|
|
651
|
+
{ local_date: '2026-08-30', count: 2 }, // inside
|
|
652
|
+
{ local_date: '2026-09-03', count: 1 }, // inside
|
|
653
|
+
];
|
|
654
|
+
const html = kit.calendarGridHtml(days, { today: '2026-09-03', weeks: 2 });
|
|
655
|
+
assert.match(html, /3 ships across 2 days/, 'the out-of-window day leaked into the summary');
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
test('CAL: calendarWindow is the one source of truth the caller reads too', () => {
|
|
659
|
+
// profile-charts.js used to recompute "N days shipped" with its own filter over the
|
|
660
|
+
// whole array; it now reads activeDays from here, so the caption and the picture
|
|
661
|
+
// cannot disagree.
|
|
662
|
+
const days = [
|
|
663
|
+
{ local_date: '2026-08-01', count: 4 },
|
|
664
|
+
{ local_date: '2026-08-30', count: 2 },
|
|
665
|
+
{ local_date: '2026-09-03', count: 1 },
|
|
666
|
+
];
|
|
667
|
+
const opts = { today: '2026-09-03', weeks: 2 };
|
|
668
|
+
const win = kit.calendarWindow(days, opts);
|
|
669
|
+
assert.equal(win.activeDays, 2);
|
|
670
|
+
assert.equal(win.totalShips, 3);
|
|
671
|
+
assert.equal(win.weeks, 2);
|
|
672
|
+
// The window's own bounds are what the grid iterates, so the naive filter the caller
|
|
673
|
+
// used to run disagrees with it — which is exactly the bug.
|
|
674
|
+
assert.notEqual(days.filter((d) => d.count > 0).length, win.activeDays);
|
|
675
|
+
const html = kit.calendarGridHtml(days, opts);
|
|
676
|
+
assert.match(html, new RegExp(`across ${win.activeDays} days`), 'grid and window disagree');
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
test('CAL: calendarWindow returns null on nothing to draw, matching the grid empty state', () => {
|
|
680
|
+
assert.equal(kit.calendarWindow([], {}), null);
|
|
681
|
+
assert.equal(kit.calendarWindow(null, {}), null);
|
|
682
|
+
assert.equal(kit.calendarWindow([{ local_date: '2026-09-03', count: 0 }], {}), null,
|
|
683
|
+
'a zero-count day is not a ship day');
|
|
684
|
+
});
|
package/tests/hall_nav.mjs
CHANGED
|
@@ -149,7 +149,12 @@ test('sidebar groups read by audience, in order', () => {
|
|
|
149
149
|
// pages followed by the two CRAFT surfaces side by side: the ideator's queue and
|
|
150
150
|
// the artist's. Same reasoning as copy-desk for why it is here rather than Docs
|
|
151
151
|
// (a place you act, not reference material) or You (its subject is the project).
|
|
152
|
-
|
|
152
|
+
// task 1003117 (R06 of goal 1000074): 'studio' — the Studio, artist mode —
|
|
153
|
+
// joins the same group AFTER copy-desk, so the two artist surfaces (the calm
|
|
154
|
+
// room and the flag table) sit side by side, both with the project's own work
|
|
155
|
+
// rather than in Docs (reference) or You (a person). Pinning the order keeps a
|
|
156
|
+
// new tab in the hall's most valuable nav real estate a reviewed decision.
|
|
157
|
+
assert.deepEqual(nav[0].items.map((i) => i.id), ['home', 'roadmap', 'goals', 'work', 'ideas', 'copy-desk', 'studio'],
|
|
153
158
|
'the unlabeled group is the project itself (task 1002425 order)');
|
|
154
159
|
assert.deepEqual(nav[1].items.map((i) => i.id), ['standing', 'profile'], 'You = your own numbers');
|
|
155
160
|
// People (task 1002577): the /scouting directory graduated into the hall.
|
|
@@ -52,6 +52,11 @@ const ORACLE = {
|
|
|
52
52
|
// every rank by design, and collab's reads (task 1003172) are scoped to the
|
|
53
53
|
// caller by the server — so for all three, sign-in IS the whole gate.
|
|
54
54
|
'/builders/copy-desk': 'requireBuilderPage',
|
|
55
|
+
// task 1003117 (R06 of goal 1000074): the Studio (artist mode) — open to every
|
|
56
|
+
// rank like copy-desk (an artist is a craft, not a rank), so sign-in is the
|
|
57
|
+
// whole gate, and its two reads (GET /tasks, GET /copy-desk/queue) already gate
|
|
58
|
+
// themselves server-side.
|
|
59
|
+
'/builders/studio': 'requireBuilderPage',
|
|
55
60
|
'/builders/ideas': 'requireBuilderPage',
|
|
56
61
|
'/builders/collab': 'requireBuilderPage',
|
|
57
62
|
// task 1002710: the orientation reading rooms moved DOWN to this tier from
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// tests/hall_studio_world.mjs — the Studio (artist mode), R06 of goal 1000074
|
|
2
|
+
// (task 1003117). The surface's load-bearing invariants, pinned so a later edit
|
|
3
|
+
// cannot quietly turn the calm room back into a form or a write path.
|
|
4
|
+
//
|
|
5
|
+
// hall_audit.mjs already holds this page to the universal rules (style.css +
|
|
6
|
+
// shell.js present, no colour literal, no page-declared pack var, no bespoke
|
|
7
|
+
// pager/chart, no sideways-scroll style). hall_nav.mjs pins the nav entry and
|
|
8
|
+
// hall_page_gate_map.mjs the sign-in gate. What is left, and lives here, is what
|
|
9
|
+
// only the Studio must be true: it writes nothing, it gates nothing, it is a
|
|
10
|
+
// room and not a table, and it IS the artist mode it claims to be.
|
|
11
|
+
//
|
|
12
|
+
// Pure file-read (no DB/network) — runs in the DB-free unit gate.
|
|
13
|
+
|
|
14
|
+
import assert from 'node:assert/strict';
|
|
15
|
+
import { test } from 'node:test';
|
|
16
|
+
import fs from 'node:fs';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import vm from 'node:vm';
|
|
19
|
+
import { fileURLToPath } from 'node:url';
|
|
20
|
+
|
|
21
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
22
|
+
const HALL = path.join(ROOT, 'modules/hall-ui/public');
|
|
23
|
+
const read = (f) => fs.readFileSync(path.join(HALL, f), 'utf8');
|
|
24
|
+
|
|
25
|
+
const HTML = read('studio.html');
|
|
26
|
+
// Comments legitimately mention markup (e.g. "the page's one <h1>"), so strip
|
|
27
|
+
// them before counting tags — the invariant is about the rendered DOM, not prose.
|
|
28
|
+
const HTML_NC = HTML.replace(/<!--[\s\S]*?-->/g, '');
|
|
29
|
+
const JS = read('studio.js');
|
|
30
|
+
const CSS = read('studio.css');
|
|
31
|
+
|
|
32
|
+
test('the Studio writes NOTHING — the goal\'s no-live-CMS rule, at the surface', () => {
|
|
33
|
+
// Every craft act (coach the grader, remake copy, propose wording) happens on
|
|
34
|
+
// the surface that owns it (the task record, the copy desk) as a claimed,
|
|
35
|
+
// graded, shipped task. This room only READS and ROUTES. A POST/PATCH/DELETE
|
|
36
|
+
// here would be the live-CMS write path the goal forbids (criterion
|
|
37
|
+
// artist-edits-land-as-tasks) — so it must not exist.
|
|
38
|
+
const writes = JS.match(/\.request\(\s*['"](POST|PATCH|PUT|DELETE)['"]/g) || [];
|
|
39
|
+
assert.deepEqual(writes, [], `studio.js must issue no writes; found ${writes.join(', ')}`);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('the reviews read is bounded and correct', () => {
|
|
43
|
+
// Review requests (cascade.js) are filed at backlog, discipline artist. They sit
|
|
44
|
+
// there until dismissed and cascade files one per qualifying ship, so the read
|
|
45
|
+
// MUST carry a limit cap — the unbounded-read shape modules/hall-ui/CLAUDE.md
|
|
46
|
+
// flags as a repeat bug. Assert the actual bound, not just the filter, so the
|
|
47
|
+
// test name does not overclaim.
|
|
48
|
+
assert.match(JS, /\/tasks\?discipline=artist&status=backlog&limit=\$\{REVIEW_LIMIT\}/, 'the backlog artist read carries a limit cap');
|
|
49
|
+
assert.match(JS, /const REVIEW_LIMIT = \d+;/, 'the cap is a named, finite constant');
|
|
50
|
+
assert.match(JS, /source === 'cascade'/, 'and rows are filtered to the cascade (review) source');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('the copy-desk read is best-effort — the room degrades, never errors', () => {
|
|
54
|
+
// An instance without the copy-desk module 404s /copy-desk/queue; the Studio
|
|
55
|
+
// must fall back to reviews alone, so the flags loader returns [] on !ok rather
|
|
56
|
+
// than throwing or blanking the room.
|
|
57
|
+
assert.match(JS, /copy-desk\/queue\?status=open/, 'flags come from the open copy-desk queue');
|
|
58
|
+
assert.match(JS, /if \(!res\.ok\) return \[\];/, 'a failed queue read yields no flags, not an error');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('it is artist mode — the room declares the mode marker', () => {
|
|
62
|
+
// The social-mode pattern (shell.js applySocial): the mode rides an attribute
|
|
63
|
+
// on the document root, set here statically so entering the room IS entering
|
|
64
|
+
// the mode with no paint flash. Losing it makes this just another page.
|
|
65
|
+
assert.match(HTML, /<html[^>]*\bdata-artist="on"/, 'the page declares data-artist="on" on <html>');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('one <main>, one <h1> — the greeting is it', () => {
|
|
69
|
+
// one-header-main-h1, checked here in the static markup so every state (incl.
|
|
70
|
+
// the no-JS fallback) keeps the single heading.
|
|
71
|
+
const h1s = HTML_NC.match(/<h1\b/g) || [];
|
|
72
|
+
assert.equal(h1s.length, 1, 'exactly one <h1>');
|
|
73
|
+
assert.match(HTML_NC, /<h1[^>]*id="studio-greet"/, 'the greeting is the page h1');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('a room, not a table — no tabular markup', () => {
|
|
77
|
+
// The criterion is explicit: "rather than another table of rows". The copy
|
|
78
|
+
// desk is the table; the Studio must not become a second one.
|
|
79
|
+
assert.ok(!/<table\b/.test(HTML_NC) && !/<table\b/.test(JS), 'the Studio renders no <table>');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('the accent is spent only where the rule allows', () => {
|
|
83
|
+
// DESIGN.md's One Warm Thing Rule: the accent lights the pressable (the primary
|
|
84
|
+
// action, .btn-accent, which the shell owns) or the LIVE. The one warm thing
|
|
85
|
+
// this sheet paints is the ambient lamp (an accent wash), documented as the
|
|
86
|
+
// room's live light; nothing else in studio.css may fill from the accent.
|
|
87
|
+
const accentFills = CSS.match(/background:\s*[^;]*var\(--accent\)[^;]*;/g) || [];
|
|
88
|
+
assert.ok(accentFills.length <= 1, `studio.css spends the accent on at most the lamp; found ${accentFills.length}`);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// Boot the real shell.js under a DOM stub (the hall_nav.mjs pattern) whose
|
|
92
|
+
// #nav-studio records classList toggles, so applyCraftEmphasis is tested for
|
|
93
|
+
// what it DOES, not just that it exists.
|
|
94
|
+
function bootShell() {
|
|
95
|
+
const src = fs.readFileSync(path.join(HALL, 'shell.js'), 'utf8');
|
|
96
|
+
const makeEl = () => {
|
|
97
|
+
const classes = new Set();
|
|
98
|
+
return {
|
|
99
|
+
dataset: {}, style: {}, title: '', textContent: '', hidden: false, _classes: classes,
|
|
100
|
+
set innerHTML(_v) {}, get innerHTML() { return ''; },
|
|
101
|
+
classList: {
|
|
102
|
+
add: (c) => classes.add(c), remove: (c) => classes.delete(c),
|
|
103
|
+
toggle: (c, on) => { const want = on === undefined ? !classes.has(c) : !!on; if (want) classes.add(c); else classes.delete(c); return want; },
|
|
104
|
+
contains: (c) => classes.has(c),
|
|
105
|
+
},
|
|
106
|
+
setAttribute() {}, getAttribute() { return null; }, removeAttribute() {},
|
|
107
|
+
addEventListener() {}, querySelectorAll() { return []; }, appendChild() {}, focus() {},
|
|
108
|
+
};
|
|
109
|
+
};
|
|
110
|
+
const els = new Map();
|
|
111
|
+
const getEl = (id) => { if (!els.has(id)) els.set(id, makeEl()); return els.get(id); };
|
|
112
|
+
const document = {
|
|
113
|
+
body: { dataset: { page: 'studio' } },
|
|
114
|
+
documentElement: getEl('__html__'),
|
|
115
|
+
getElementById: getEl,
|
|
116
|
+
querySelectorAll() { return []; },
|
|
117
|
+
createElement() { return makeEl(); },
|
|
118
|
+
addEventListener() {}, dispatchEvent() {}, get cookie() { return ''; }, set cookie(_v) {},
|
|
119
|
+
};
|
|
120
|
+
const location = { hostname: 'builders.cloudbongos.com', origin: 'https://builders.cloudbongos.com', pathname: '/studio', hash: '', assign() {}, replace() {} };
|
|
121
|
+
const window = {
|
|
122
|
+
__BRANDING__: {}, __MODULES__: { enabled: {} },
|
|
123
|
+
BongosClient: { createClient: () => ({ request: () => new Promise(() => {}) }) },
|
|
124
|
+
matchMedia: () => ({ matches: false, addEventListener() {} }),
|
|
125
|
+
localStorage: { getItem() { return null; }, setItem() {} },
|
|
126
|
+
requestAnimationFrame() {}, scrollTo() {}, location, addEventListener() {},
|
|
127
|
+
};
|
|
128
|
+
const sandbox = { window, document, location, console, setTimeout, clearTimeout, CustomEvent: class {} };
|
|
129
|
+
sandbox.globalThis = sandbox;
|
|
130
|
+
vm.createContext(sandbox);
|
|
131
|
+
vm.runInContext(src, sandbox, { filename: 'shell.js' });
|
|
132
|
+
return { applyAccess: window.OTBShell.applyAccess, navStudio: () => getEl('nav-studio') };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
test('the Studio nav entry is warmed only for an artist-craft builder', () => {
|
|
136
|
+
const { applyAccess, navStudio } = bootShell();
|
|
137
|
+
|
|
138
|
+
applyAccess({ authed: true, rank: 'archon', builder: { display_name: 'Artist A', preferred_disciplines: ['artist', 'ideator'] } });
|
|
139
|
+
assert.ok(navStudio()._classes.has('nav-item--craft'), 'an artist-craft builder gets the warmed entry');
|
|
140
|
+
|
|
141
|
+
applyAccess({ authed: true, rank: 'archon', builder: { display_name: 'Eng E', preferred_disciplines: ['engineer'] } });
|
|
142
|
+
assert.ok(!navStudio()._classes.has('nav-item--craft'), 'and a non-artist builder does not (the class is toggled off)');
|
|
143
|
+
});
|
package/tests/skill_lint.mjs
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
import { strict as assert } from 'node:assert';
|
|
4
4
|
import { test } from 'node:test';
|
|
5
5
|
import { createRequire } from 'node:module';
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import os from 'node:os';
|
|
6
8
|
import path from 'node:path';
|
|
7
9
|
import { fileURLToPath } from 'node:url';
|
|
8
10
|
|
|
@@ -58,11 +60,58 @@ test('no frontmatter at all is an error', () => {
|
|
|
58
60
|
assert.equal(run('# Title\n\nBody.\n').errors.length, 1);
|
|
59
61
|
});
|
|
60
62
|
|
|
61
|
-
test('the hook path filter matches
|
|
63
|
+
test('the hook path filter matches a SKILL.md in either home — .claude/skills/ or a module (task 1003587)', () => {
|
|
62
64
|
assert.ok(lint.isSkillMd('C:\\repo\\.claude\\skills\\dev\\SKILL.md'));
|
|
63
65
|
assert.ok(lint.isSkillMd('/repo/.claude/skills/dev/SKILL.md'));
|
|
66
|
+
// The module copy is the file a builder EDITS; .claude/skills/ holds the generated
|
|
67
|
+
// copy, so a hook that only watched the tracked root never saw the real edit.
|
|
68
|
+
assert.ok(lint.isSkillMd('/repo/modules/ui-design/skills/dev/SKILL.md'));
|
|
69
|
+
assert.ok(lint.isSkillMd('C:\\repo\\modules\\ui-design\\skills\\vendor\\dev\\SKILL.md'));
|
|
64
70
|
assert.ok(!lint.isSkillMd('/repo/.claude/skills/dev/notes.md'));
|
|
65
|
-
assert.ok(!lint.isSkillMd('/repo/modules/ui-design/skills/dev/
|
|
71
|
+
assert.ok(!lint.isSkillMd('/repo/modules/ui-design/skills/dev/notes.md'));
|
|
72
|
+
assert.ok(!lint.isSkillMd('/repo/docs/skills/dev/SKILL.md'));
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// A fixture root, because a real dev checkout may or may not have materialized its
|
|
76
|
+
// module skills into .claude/skills/ (claude-materialize.js, task 1003330) and the
|
|
77
|
+
// lister's contract must hold either way.
|
|
78
|
+
function fixtureRoot(files) {
|
|
79
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'skill-lint-'));
|
|
80
|
+
for (const [rel, body] of Object.entries(files)) {
|
|
81
|
+
const abs = path.join(root, rel);
|
|
82
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
83
|
+
fs.writeFileSync(abs, body);
|
|
84
|
+
}
|
|
85
|
+
return root;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
test('the lister reaches module-owned skills, and a core copy of the same name wins (task 1003587)', () => {
|
|
89
|
+
const skill = fm('>-\n A description long enough for the harness to route on, written as a block scalar.');
|
|
90
|
+
const root = fixtureRoot({
|
|
91
|
+
'.claude/skills/only-core/SKILL.md': skill,
|
|
92
|
+
'.claude/skills/both/SKILL.md': skill,
|
|
93
|
+
'modules/ui-design/skills/both/SKILL.md': skill,
|
|
94
|
+
'modules/ui-design/skills/only-module/SKILL.md': skill,
|
|
95
|
+
'modules/ui-design/skills/vendor/vendored/SKILL.md': skill,
|
|
96
|
+
'modules/ui-design/skills/README.md': '# not a skill\n',
|
|
97
|
+
});
|
|
98
|
+
const files = lint.listSkillFiles(root).map((f) => path.relative(root, f).replace(/\\/g, '/'));
|
|
99
|
+
assert.deepEqual(files.sort(), [
|
|
100
|
+
'.claude/skills/both/SKILL.md',
|
|
101
|
+
'.claude/skills/only-core/SKILL.md',
|
|
102
|
+
'modules/ui-design/skills/only-module/SKILL.md',
|
|
103
|
+
'modules/ui-design/skills/vendor/vendored/SKILL.md',
|
|
104
|
+
], 'a module skill is linted at its source; a name already in .claude/skills/ is not counted twice');
|
|
105
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('every module-owned skill this repo ships parses too — the 14 that did not on 2026-09-04 (task 1003587)', () => {
|
|
109
|
+
const dir = path.join(ROOT, 'modules', 'ui-design', 'skills');
|
|
110
|
+
const files = lint.listSkillFiles(ROOT).filter((f) => f.startsWith(dir) || fs.existsSync(path.join(dir, path.basename(path.dirname(f)), 'SKILL.md')));
|
|
111
|
+
assert.ok(files.length >= 14, `the ui-design module ships 14+ skills; found ${files.length}`);
|
|
112
|
+
const report = lint.lintSkills(files);
|
|
113
|
+
const broken = report.results.filter((r) => r.errors.length).map((r) => path.relative(ROOT, r.file) + ': ' + r.errors.join('; '));
|
|
114
|
+
assert.deepEqual(broken, [], broken.join('\n'));
|
|
66
115
|
});
|
|
67
116
|
|
|
68
117
|
test('every committed skill parses (the ten that did not on 2026-09-03 must stay fixed)', () => {
|
package/tests/spike_task_map.mjs
CHANGED
|
Binary file
|
|
@@ -34,14 +34,24 @@ const policy = readJson(path.join(SKILLS, 'policy.json'));
|
|
|
34
34
|
const manifest = readJson(path.join(MODULE, 'module.json'));
|
|
35
35
|
const materialize = require(path.join(ROOT, 'scripts', 'gds', 'claude-materialize.js'));
|
|
36
36
|
|
|
37
|
-
// Minimal YAML-frontmatter reader: `key: value` lines between the first two `---`
|
|
37
|
+
// Minimal YAML-frontmatter reader: `key: value` lines between the first two `---`
|
|
38
|
+
// fences, plus folded block scalars (`key: >-` then the text indented), which is the
|
|
39
|
+
// only safe way to write a description carrying a colon-space (task 1003587) — a plain
|
|
40
|
+
// scalar with ": " in it makes Claude Code's YAML reader drop EVERY field silently.
|
|
38
41
|
function frontmatter(text) {
|
|
39
42
|
const m = text.match(/^---\n([\s\S]*?)\n---\n/);
|
|
40
43
|
const out = {};
|
|
41
44
|
if (!m) return out;
|
|
45
|
+
let block = null; // the key whose folded value the indented lines below belong to
|
|
42
46
|
for (const line of m[1].split('\n')) {
|
|
43
47
|
const kv = line.match(/^([A-Za-z][A-Za-z0-9_-]*):\s*(.*)$/);
|
|
44
|
-
if (kv)
|
|
48
|
+
if (kv) {
|
|
49
|
+
const v = kv[2].trim();
|
|
50
|
+
block = /^[>|][+-]?\d*$/.test(v) ? kv[1] : null;
|
|
51
|
+
out[kv[1]] = block ? '' : v.replace(/^["']|["']$/g, '').trim();
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (block && /^\s+\S/.test(line)) out[block] = (out[block] ? out[block] + ' ' : '') + line.trim();
|
|
45
55
|
}
|
|
46
56
|
return out;
|
|
47
57
|
}
|