@bongos/core 1.19.819 → 1.19.820
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 +60 -25
- package/clients/bongos-client/index.d.ts +1 -1
- package/docs/api/openapi.json +4 -2
- package/docs/copy-inventory.md +23 -18
- package/docs/copy-registry.json +76 -31
- package/docs/module-api-changelog.md +3 -1
- package/migrations/core_246_session_type.sql +49 -0
- package/modules/hall-ui/public/sessions.css +19 -0
- package/modules/hall-ui/public/sessions.html +16 -1
- package/modules/hall-ui/public/sessions.js +135 -6
- package/modules/sessions/db.js +92 -6
- package/modules/sessions/routes/sessions.js +18 -1
- package/modules/sessions/session-type.js +132 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/backfill-session-types.js +87 -0
- package/scripts/gds/fitness.js +12 -1
- package/scripts/gds/home-isolation-guard.js +126 -0
- package/scripts/gds/ship-merge.js +5 -1
- package/scripts/gds/ship-regen.js +29 -0
- package/src/module-api.js +1 -1
- package/tests/cli_package.mjs +18 -2
- package/tests/hall_roster_world.mjs +10 -5
- package/tests/home_isolation_guard.mjs +95 -0
- package/tests/session_type.mjs +215 -0
- package/tests/ship_regen_copy_inventory.mjs +69 -0
package/modules/sessions/db.js
CHANGED
|
@@ -28,6 +28,11 @@ const { pool } = api;
|
|
|
28
28
|
// file, not the doorway: it touches no kernel surface, and keeping the numbers
|
|
29
29
|
// in one place is what stops the route and the poller disagreeing.
|
|
30
30
|
const uploadHealth = require('./upload-health');
|
|
31
|
+
// The PURE session-type verdict (task 1003975). Same reasoning as upload-health:
|
|
32
|
+
// the classifier touches no kernel surface, and keeping the vocabulary in one
|
|
33
|
+
// file is what stops the upload path, the search filter and the backfill from
|
|
34
|
+
// disagreeing about what a type is.
|
|
35
|
+
const sessionType = require('./session-type');
|
|
31
36
|
// `seams` is the doorway's kernel-mediated seam surface (resolveOptional/resolve).
|
|
32
37
|
// upsertSessionRecordWithReward resolves the economy `reward` port through it —
|
|
33
38
|
// the module↔module seam, never a sibling-module require (BV1.R77 / ADR 0093 §2).
|
|
@@ -64,11 +69,19 @@ async function upsertSessionRecordTx(executor, {
|
|
|
64
69
|
const ids = Array.isArray(taskIds)
|
|
65
70
|
? taskIds.map((n) => Number(n)).filter(Number.isFinite)
|
|
66
71
|
: [];
|
|
72
|
+
// task 1003975 — the session's shape, derived here and NEVER taken from the
|
|
73
|
+
// body. Computed on the same digest the row stores, so the column can always be
|
|
74
|
+
// re-derived from what is beside it; an upload that re-posts a session (the ship
|
|
75
|
+
// path uploads once per shipped task) re-classifies it from the newest digest,
|
|
76
|
+
// which is the same rule detail/metrics already follow on conflict.
|
|
77
|
+
const type = sessionType.classifySession({ detail, metrics });
|
|
67
78
|
const { rows } = await executor.query(
|
|
68
79
|
`INSERT INTO session_records
|
|
69
80
|
(session_id, builder_id, task_ids, drachmae_earned, total_tokens,
|
|
70
|
-
duration_seconds, started_at, ended_at, detail, metrics, model_usage
|
|
71
|
-
|
|
81
|
+
duration_seconds, started_at, ended_at, detail, metrics, model_usage,
|
|
82
|
+
session_type)
|
|
83
|
+
VALUES ($1, $2, $3::bigint[], $4, $5, $6, $7, $8, $9::jsonb, $10::jsonb, $11::jsonb,
|
|
84
|
+
$12)
|
|
72
85
|
ON CONFLICT (session_id) DO UPDATE SET
|
|
73
86
|
task_ids = (
|
|
74
87
|
SELECT COALESCE(array_agg(DISTINCT e ORDER BY e), '{}')
|
|
@@ -84,12 +97,13 @@ async function upsertSessionRecordTx(executor, {
|
|
|
84
97
|
detail = EXCLUDED.detail,
|
|
85
98
|
metrics = EXCLUDED.metrics,
|
|
86
99
|
model_usage = EXCLUDED.model_usage,
|
|
100
|
+
session_type = EXCLUDED.session_type,
|
|
87
101
|
uploaded_at = now()
|
|
88
102
|
-- Ownership guard: a row can only ever be updated by its own builder, so a
|
|
89
103
|
-- builder who guesses another's session_id cannot overwrite their record.
|
|
90
104
|
WHERE session_records.builder_id = EXCLUDED.builder_id
|
|
91
105
|
RETURNING id, session_id, builder_id, task_ids, drachmae_earned,
|
|
92
|
-
total_tokens, duration_seconds, uploaded_at`,
|
|
106
|
+
total_tokens, duration_seconds, session_type, uploaded_at`,
|
|
93
107
|
[
|
|
94
108
|
sessionId,
|
|
95
109
|
builderId,
|
|
@@ -102,6 +116,7 @@ async function upsertSessionRecordTx(executor, {
|
|
|
102
116
|
JSON.stringify(detail ?? []),
|
|
103
117
|
JSON.stringify(metrics ?? {}),
|
|
104
118
|
JSON.stringify(modelUsage ?? {}),
|
|
119
|
+
type,
|
|
105
120
|
]
|
|
106
121
|
);
|
|
107
122
|
return rows[0] || null; // null only on the ownership-guard no-op (foreign session_id)
|
|
@@ -521,9 +536,14 @@ function clampSessionOffset(n) {
|
|
|
521
536
|
|
|
522
537
|
// sessionFilterClause — the shared parameterized WHERE used by BOTH searchSessions
|
|
523
538
|
// (the page) and summarizeSessions (the corpus-wide tally), so the two can never
|
|
524
|
-
// drift out of sync. Every filter is a bound parameter ($1..$
|
|
539
|
+
// drift out of sync. Every filter is a bound parameter ($1..$8); nothing from the
|
|
525
540
|
// caller is interpolated. Returns { clause, params } where clause is the body of
|
|
526
541
|
// a WHERE (no leading 'WHERE'). Pure.
|
|
542
|
+
//
|
|
543
|
+
// task 1003975 added $8 (sessionType). It goes through the SHARED clause like
|
|
544
|
+
// every other facet precisely so the Tally cannot disagree with the rows: a
|
|
545
|
+
// filter applied to the page query alone would show "4,595 sessions" above a
|
|
546
|
+
// table holding 57.
|
|
527
547
|
function sessionFilterClause(f = {}) {
|
|
528
548
|
const clause = `($1::text IS NULL OR b.rank = $1)
|
|
529
549
|
AND ($2::int IS NULL OR b.karma >= $2)
|
|
@@ -531,7 +551,8 @@ function sessionFilterClause(f = {}) {
|
|
|
531
551
|
AND ($4::bigint IS NULL OR sr.total_tokens >= $4)
|
|
532
552
|
AND ($5::int IS NULL OR COALESCE((sr.metrics->>'tool_error_count')::int, 0) >= $5)
|
|
533
553
|
AND ($6::int IS NULL OR sr.duration_seconds >= $6)
|
|
534
|
-
AND ($7::int IS NULL OR (now() - b.created_at) <= ($7 * interval '1 day'))
|
|
554
|
+
AND ($7::int IS NULL OR (now() - b.created_at) <= ($7 * interval '1 day'))
|
|
555
|
+
AND ($8::text IS NULL OR sr.session_type = $8)`;
|
|
535
556
|
const params = [
|
|
536
557
|
f.rank ?? null,
|
|
537
558
|
f.minKarma ?? null,
|
|
@@ -540,6 +561,7 @@ function sessionFilterClause(f = {}) {
|
|
|
540
561
|
f.minToolErrors ?? null,
|
|
541
562
|
f.minDurationSec ?? null,
|
|
542
563
|
f.maxTenureDays ?? null,
|
|
564
|
+
f.sessionType ?? null,
|
|
543
565
|
];
|
|
544
566
|
return { clause, params };
|
|
545
567
|
}
|
|
@@ -559,6 +581,10 @@ const SESSION_SUMMARY_COLS = `
|
|
|
559
581
|
-- the ledger does not have to re-derive the thresholds in the browser.
|
|
560
582
|
(sr.metrics->'accessibility'->>'score')::numeric AS accessibility_score,
|
|
561
583
|
(sr.metrics->'accessibility'->>'band') AS accessibility_band,
|
|
584
|
+
-- task 1003975: the session's derived shape. NOT coalesced to a type — a NULL
|
|
585
|
+
-- here means the row predates the classifier and the backfill has not reached
|
|
586
|
+
-- it, which the ledger renders as a dash rather than claiming a verdict.
|
|
587
|
+
sr.session_type,
|
|
562
588
|
sr.uploaded_at`;
|
|
563
589
|
|
|
564
590
|
// searchSessions — cross-builder search over the corpus, filtered by session
|
|
@@ -574,7 +600,7 @@ async function searchSessions(filters = {}) {
|
|
|
574
600
|
const { clause, params } = sessionFilterClause(f);
|
|
575
601
|
// limit/offset are clamped integers, never raw input — safe to interpolate;
|
|
576
602
|
// keeping them out of the param list keeps the $-indices aligned with the
|
|
577
|
-
// shared clause (which owns $1..$
|
|
603
|
+
// shared clause (which owns $1..$8).
|
|
578
604
|
const { rows } = await pool.query(
|
|
579
605
|
`SELECT ${SESSION_SUMMARY_COLS}
|
|
580
606
|
FROM session_records sr
|
|
@@ -772,6 +798,62 @@ async function sessionUploadHealth({ days, windowHours, sampleLimit } = {}) {
|
|
|
772
798
|
};
|
|
773
799
|
}
|
|
774
800
|
|
|
801
|
+
// -------------------------------------------------------------------------
|
|
802
|
+
// Session-type backfill (task 1003975)
|
|
803
|
+
// -------------------------------------------------------------------------
|
|
804
|
+
//
|
|
805
|
+
// The two halves the CLI needs, and NOTHING else. They are here rather than in
|
|
806
|
+
// the script because every other query in this domain is, and because the write
|
|
807
|
+
// half has to be narrow in a way a script cannot enforce for itself: it SETs one
|
|
808
|
+
// column on one id. It cannot touch task_ids, drachmae_earned or any reward
|
|
809
|
+
// column even by accident, which is the lesson the windowed attribution sweep
|
|
810
|
+
// paid for (see the warning in this module's CLAUDE.md) — a backfill that also
|
|
811
|
+
// moves money is the wrong shape whatever its intent.
|
|
812
|
+
|
|
813
|
+
// unclassifiedSessions — a page of rows still missing a type, oldest first, with
|
|
814
|
+
// the two jsonb fields the classifier reads. Ordered by id so a resumed run walks
|
|
815
|
+
// the corpus deterministically rather than re-drawing a shuffled window.
|
|
816
|
+
async function unclassifiedSessions(limit = 500) {
|
|
817
|
+
const { rows } = await pool.query(
|
|
818
|
+
`SELECT id, detail, metrics
|
|
819
|
+
FROM session_records
|
|
820
|
+
WHERE session_type IS NULL
|
|
821
|
+
ORDER BY id ASC
|
|
822
|
+
LIMIT $1`,
|
|
823
|
+
[Math.max(1, Math.min(5000, Math.trunc(Number(limit) || 500)))]
|
|
824
|
+
);
|
|
825
|
+
return rows;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
// setSessionType — write one row's verdict. Refuses anything outside the closed
|
|
829
|
+
// set, so a caller that invents a type gets an error instead of a column full of
|
|
830
|
+
// values no filter will ever match.
|
|
831
|
+
async function setSessionType(id, type) {
|
|
832
|
+
if (!sessionType.isSessionType(type)) {
|
|
833
|
+
const err = new Error(`unknown session_type '${type}'`);
|
|
834
|
+
err.code = 'BAD_SESSION_TYPE';
|
|
835
|
+
throw err;
|
|
836
|
+
}
|
|
837
|
+
const { rowCount } = await pool.query(
|
|
838
|
+
'UPDATE session_records SET session_type = $2 WHERE id = $1',
|
|
839
|
+
[Number(id), type]
|
|
840
|
+
);
|
|
841
|
+
return rowCount;
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
// sessionTypeCounts — the corpus tally per type, including the NULL backlog. The
|
|
845
|
+
// backfill prints it as its own progress, and it answers "what is this corpus
|
|
846
|
+
// actually made of" in one query.
|
|
847
|
+
async function sessionTypeCounts() {
|
|
848
|
+
const { rows } = await pool.query(
|
|
849
|
+
`SELECT COALESCE(session_type, 'unclassified') AS type, COUNT(*)::int AS n
|
|
850
|
+
FROM session_records
|
|
851
|
+
GROUP BY 1
|
|
852
|
+
ORDER BY 2 DESC`
|
|
853
|
+
);
|
|
854
|
+
return rows;
|
|
855
|
+
}
|
|
856
|
+
|
|
775
857
|
module.exports = {
|
|
776
858
|
// write side (6D.1)
|
|
777
859
|
upsertSessionRecord,
|
|
@@ -793,4 +875,8 @@ module.exports = {
|
|
|
793
875
|
resolveSessionOrder,
|
|
794
876
|
clampSessionLimit,
|
|
795
877
|
clampSessionOffset,
|
|
878
|
+
// session type (task 1003975) — the backfill's two halves + the corpus tally
|
|
879
|
+
unclassifiedSessions,
|
|
880
|
+
setSessionType,
|
|
881
|
+
sessionTypeCounts,
|
|
796
882
|
};
|
|
@@ -46,6 +46,10 @@ const { scoreAccessibility } = require('../accessibility');
|
|
|
46
46
|
// `days=99999` for a read that ran at 365 is a record of something that did not
|
|
47
47
|
// happen (task 1003865).
|
|
48
48
|
const uploadHealth = require('../upload-health');
|
|
49
|
+
// The closed set of session types + its guard (task 1003975). The route validates
|
|
50
|
+
// the ?session_type filter against the SAME list the classifier writes, so a type
|
|
51
|
+
// can never be filterable on one surface and unwritable on the other.
|
|
52
|
+
const sessionType = require('../session-type');
|
|
49
53
|
const seams = api; // resolveOptional('reward' | 'builder-settings') — kernel-mediated ports
|
|
50
54
|
const { validateOrRespond, parseId } = api;
|
|
51
55
|
|
|
@@ -321,6 +325,13 @@ module.exports = function buildSessionsRouter() {
|
|
|
321
325
|
if (rank && !VALID_RANKS.has(rank)) {
|
|
322
326
|
return res.fail('bad_rank', 400, { valid: Array.from(VALID_RANKS) });
|
|
323
327
|
}
|
|
328
|
+
// task 1003975 — validated against the classifier's own closed set, so a
|
|
329
|
+
// typo is a named 400 rather than a query that quietly matches nothing and
|
|
330
|
+
// reads as "the corpus holds no exploration sessions".
|
|
331
|
+
const sessionTypeFilter = q.session_type ? String(q.session_type) : null;
|
|
332
|
+
if (sessionTypeFilter && !sessionType.isSessionType(sessionTypeFilter)) {
|
|
333
|
+
return res.fail('bad_session_type', 400, { valid: sessionType.SESSION_TYPES });
|
|
334
|
+
}
|
|
324
335
|
const numOrNull = (v) => {
|
|
325
336
|
if (v == null) return null;
|
|
326
337
|
const n = Number(v);
|
|
@@ -334,6 +345,7 @@ module.exports = function buildSessionsRouter() {
|
|
|
334
345
|
minToolErrors: numOrNull(q.min_tool_errors),
|
|
335
346
|
minDurationSec: numOrNull(q.min_duration_seconds),
|
|
336
347
|
maxTenureDays: numOrNull(q.max_tenure_days),
|
|
348
|
+
sessionType: sessionTypeFilter,
|
|
337
349
|
order: q.order ? String(q.order) : 'recent',
|
|
338
350
|
limit: q.limit,
|
|
339
351
|
offset: q.offset,
|
|
@@ -345,7 +357,7 @@ module.exports = function buildSessionsRouter() {
|
|
|
345
357
|
await recordSessionRead(req, {
|
|
346
358
|
route: '/api/bongos/sessions/search',
|
|
347
359
|
targetBuilderId: null,
|
|
348
|
-
target: `search rank=${rank || '*'} order=${filters.order} offset=${db.clampSessionOffset(filters.offset)}`,
|
|
360
|
+
target: `search rank=${rank || '*'} type=${sessionTypeFilter || '*'} order=${filters.order} offset=${db.clampSessionOffset(filters.offset)}`,
|
|
349
361
|
});
|
|
350
362
|
// Page + corpus-wide tally in parallel. `totals` reflects EVERY session
|
|
351
363
|
// matching the filter (not just this page), so the Ledger's stats stay
|
|
@@ -361,6 +373,11 @@ module.exports = function buildSessionsRouter() {
|
|
|
361
373
|
totals,
|
|
362
374
|
limit: db.clampSessionLimit(filters.limit),
|
|
363
375
|
offset: db.clampSessionOffset(filters.offset),
|
|
376
|
+
// task 1003975 — the vocabulary travels WITH the results so the ledger
|
|
377
|
+
// builds its filter from what this route actually accepts. A page that
|
|
378
|
+
// hardcoded the list would keep offering a type the server had dropped,
|
|
379
|
+
// and would hide one it had added.
|
|
380
|
+
session_types: sessionType.SESSION_TYPES,
|
|
364
381
|
});
|
|
365
382
|
} catch (err) {
|
|
366
383
|
console.error('[gds] GET /sessions/search', err);
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// modules/sessions/session-type.js — WHAT A SESSION WAS, FOR A LEDGER THAT ONLY
|
|
4
|
+
// KNEW WHETHER IT SHIPPED A TASK (task 1003975, from idea 1001033).
|
|
5
|
+
//
|
|
6
|
+
// THE PROBLEM THIS ANSWERS. The session ledger shows a task chip per row, and a
|
|
7
|
+
// dash when there is none. 54% of the corpus has an empty `task_ids` (the figure
|
|
8
|
+
// upload-health.js already records), so more than half the ledger reads as a
|
|
9
|
+
// column of dashes — every one of them some real session, none of them
|
|
10
|
+
// distinguishable from the next. The owner's words: user behaviour is hard to
|
|
11
|
+
// decipher at present.
|
|
12
|
+
//
|
|
13
|
+
// WHAT THE CORPUS ACTUALLY CONTAINS, MEASURED — and it is not what the ledger's
|
|
14
|
+
// shape implies. 247 sessions were sampled through GET /sessions/:id across
|
|
15
|
+
// offsets 0…3600 of the recent ordering (2026-09-17, corpus of 4595):
|
|
16
|
+
//
|
|
17
|
+
// automated 179 (72%) build 57 (23%) exploration 11 (4%) conversation 0
|
|
18
|
+
//
|
|
19
|
+
// Two findings drove the design:
|
|
20
|
+
//
|
|
21
|
+
// * MOST ROWS ARE NOT HUMAN BEHAVIOUR AT ALL. 188 of 247 sessions received one
|
|
22
|
+
// prompt or fewer — grader workers, panel subagents and hook one-shots, each
|
|
23
|
+
// a separate `claude -p` process with its own session id and its own
|
|
24
|
+
// SessionEnd upload. Reading the ledger as a record of what PEOPLE did is
|
|
25
|
+
// what makes it undecipherable.
|
|
26
|
+
// * `task_ids` DOES NOT MEAN "the session that built the task". 82 of those 179
|
|
27
|
+
// automated rows carry a task id, because a grader worker runs inside a ship
|
|
28
|
+
// and inherits it. So the task chip cannot be the primary signal, and this
|
|
29
|
+
// classifier deliberately never reads `task_ids`.
|
|
30
|
+
//
|
|
31
|
+
// THE SIGNAL THAT SEPARATES A PERSON FROM A SCRIPT is the prompt count:
|
|
32
|
+
// `user_turns - tool_result_count`. Every tool result is recorded as a user turn,
|
|
33
|
+
// so subtracting them leaves the turns a human (or an orchestrating skill)
|
|
34
|
+
// actually sent. Measured over the sample it is strikingly bimodal — 184 rows sit
|
|
35
|
+
// at exactly 1 and 4 at 0, against a long tail from 2 to 78 — which is why the
|
|
36
|
+
// boundary is `<= 1` and not a tuned percentile. It is the difference between
|
|
37
|
+
// "invoked once" and "steered", not a threshold anyone chose.
|
|
38
|
+
//
|
|
39
|
+
// PURE, AND DELIBERATELY SO. No DB, no clock, no network — the same shape as
|
|
40
|
+
// upload-health.js's verdict. It takes the digest a session already uploads and
|
|
41
|
+
// returns one string. Nothing here calls an LLM: a paid inference per session
|
|
42
|
+
// would cost more than the corpus is worth, and a deterministic read of stored
|
|
43
|
+
// data is reproducible when someone disputes a label.
|
|
44
|
+
|
|
45
|
+
// The CLOSED set of types. Anything outside it is a bug, not a new category —
|
|
46
|
+
// the column, the route filter and the ledger's control all validate against
|
|
47
|
+
// this one list, so a type cannot exist on one surface and not another.
|
|
48
|
+
const SESSION_TYPES = ['build', 'automated', 'exploration', 'conversation', 'unknown'];
|
|
49
|
+
|
|
50
|
+
// The tools that CHANGE something. Kept as a named set rather than inlined
|
|
51
|
+
// because it is the one judgement in the file that will need revisiting when the
|
|
52
|
+
// harness grows a new editor — a new write tool missing from here silently
|
|
53
|
+
// demotes real build sessions to exploration, which is the failure mode to watch.
|
|
54
|
+
// Bash is NOT here on purpose: it reads at least as often as it writes (1314 of
|
|
55
|
+
// the sample's 1761 tool calls), so counting it as a write would make almost
|
|
56
|
+
// every session a build and the label would stop meaning anything.
|
|
57
|
+
const WRITE_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit']);
|
|
58
|
+
|
|
59
|
+
// The prompt count above which a session counts as steered rather than invoked.
|
|
60
|
+
// Measured, not chosen — see the header.
|
|
61
|
+
const ONE_SHOT_PROMPT_MAX = 1;
|
|
62
|
+
|
|
63
|
+
// humanPrompts — the turns a person actually sent: user turns minus the tool
|
|
64
|
+
// results that are also recorded as user turns. Clamped at 0, because a digest
|
|
65
|
+
// whose counters disagree (an old row, a truncated transcript) must not produce a
|
|
66
|
+
// negative that then reads as a one-shot.
|
|
67
|
+
function humanPrompts(metrics) {
|
|
68
|
+
const m = metrics || {};
|
|
69
|
+
const userTurns = Number(m.user_turns) || 0;
|
|
70
|
+
const toolResults = Number(m.tool_result_count) || 0;
|
|
71
|
+
return Math.max(0, userTurns - toolResults);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// usedWriteTool — did any turn call a tool that changes a file? Reads `detail`,
|
|
75
|
+
// which is the only place tool NAMES survive (metrics counts calls, not what they
|
|
76
|
+
// were). Tolerant of every shape an old row might hold — a missing detail, a
|
|
77
|
+
// turn with no tools, a tools entry that is not a string — because this runs over
|
|
78
|
+
// a corpus written by several versions of the digest builder.
|
|
79
|
+
function usedWriteTool(detail) {
|
|
80
|
+
if (!Array.isArray(detail)) return false;
|
|
81
|
+
for (const turn of detail) {
|
|
82
|
+
if (!turn || !Array.isArray(turn.tools)) continue;
|
|
83
|
+
for (const name of turn.tools) {
|
|
84
|
+
if (WRITE_TOOLS.has(String(name))) return true;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// classifySession — the verdict: one of SESSION_TYPES, from a session's own
|
|
91
|
+
// digest. Takes `{ detail, metrics }` — exactly what the upload body carries and
|
|
92
|
+
// what a stored row holds — so the upload path and the backfill classify through
|
|
93
|
+
// the same call and cannot drift.
|
|
94
|
+
//
|
|
95
|
+
// The order of the tests is the meaning:
|
|
96
|
+
// 1. It wrote something → `build`, whatever else it did.
|
|
97
|
+
// 2. It was invoked, not steered → `automated` (the grader worker, the hook).
|
|
98
|
+
// 3. It used tools → `exploration` (someone reading the codebase).
|
|
99
|
+
// 4. It used none → `conversation` (someone asking, not building).
|
|
100
|
+
// A digest with no usable counters at all returns `unknown` rather than guessing:
|
|
101
|
+
// an empty transcript is not evidence of a quiet conversation.
|
|
102
|
+
function classifySession({ detail, metrics } = {}) {
|
|
103
|
+
const m = metrics && typeof metrics === 'object' ? metrics : null;
|
|
104
|
+
const hasDetail = Array.isArray(detail) && detail.length > 0;
|
|
105
|
+
if (!m && !hasDetail) return 'unknown';
|
|
106
|
+
if (usedWriteTool(detail)) return 'build';
|
|
107
|
+
const turns = Number(m && m.assistant_turns) || 0;
|
|
108
|
+
const toolUse = Number(m && m.tool_use_count) || 0;
|
|
109
|
+
// Nothing in the digest describes a session: no turns, no tools, no prompts.
|
|
110
|
+
// Older rows predating these counters land here, and so does a row whose
|
|
111
|
+
// upload was truncated. Saying `unknown` keeps them out of every real bucket
|
|
112
|
+
// instead of inflating `conversation` with rows nobody can account for.
|
|
113
|
+
if (!turns && !toolUse && !hasDetail) return 'unknown';
|
|
114
|
+
if (humanPrompts(m) <= ONE_SHOT_PROMPT_MAX) return 'automated';
|
|
115
|
+
return toolUse > 0 ? 'exploration' : 'conversation';
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// isSessionType — the guard the route filter uses, so an unknown ?session_type
|
|
119
|
+
// is a clean 400 rather than a query that silently matches nothing.
|
|
120
|
+
function isSessionType(value) {
|
|
121
|
+
return typeof value === 'string' && SESSION_TYPES.includes(value);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
module.exports = {
|
|
125
|
+
SESSION_TYPES,
|
|
126
|
+
WRITE_TOOLS,
|
|
127
|
+
ONE_SHOT_PROMPT_MAX,
|
|
128
|
+
humanPrompts,
|
|
129
|
+
usedWriteTool,
|
|
130
|
+
classifySession,
|
|
131
|
+
isSessionType,
|
|
132
|
+
};
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.820",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.820",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.820",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
// scripts/gds/backfill-session-types.js — classify the sessions that predate the
|
|
5
|
+
// classifier (task 1003975, from idea 1001033).
|
|
6
|
+
//
|
|
7
|
+
// WHAT THIS IS, and what it is NOT. It writes ONE column, `session_records.session_type`,
|
|
8
|
+
// and only where that column is NULL. It moves no money, books no reward, touches
|
|
9
|
+
// no `task_ids` and creates no attribution. That separation is deliberate and it
|
|
10
|
+
// is the lesson this module already paid for once: the attribution sweep had to be
|
|
11
|
+
// windowed because it ran on the same tick as a payout and would have minted a mass
|
|
12
|
+
// retroactive payment nobody authorised (see the warning in modules/sessions/CLAUDE.md).
|
|
13
|
+
// A sweep that both labels and pays is the wrong shape whatever its intent, so this
|
|
14
|
+
// one cannot pay even in principle — db.setSessionType writes that column alone.
|
|
15
|
+
//
|
|
16
|
+
// WHY IT IS NEEDED. The upload path classifies every session from now on, but the
|
|
17
|
+
// corpus is ~4,600 rows old and none of them carry a verdict. Without this pass the
|
|
18
|
+
// ledger's new filter would only ever see today's sessions, which is exactly the
|
|
19
|
+
// half of the corpus nobody needs help reading.
|
|
20
|
+
//
|
|
21
|
+
// SAFETY. DRY-RUN BY DEFAULT: with no flags it classifies in memory, prints the
|
|
22
|
+
// distribution it WOULD write, and writes nothing. `--apply` performs the UPDATEs.
|
|
23
|
+
// Idempotent by construction: the verdict is a pure function of the row's own
|
|
24
|
+
// stored digest, so a re-run lands on the same answer, and a row that already has
|
|
25
|
+
// a type is never revisited (the query selects `WHERE session_type IS NULL`).
|
|
26
|
+
// `--limit N` bounds one pass so the first apply can be small and checked.
|
|
27
|
+
//
|
|
28
|
+
// Run: node scripts/gds/backfill-session-types.js [--limit N]
|
|
29
|
+
// node scripts/gds/backfill-session-types.js --apply
|
|
30
|
+
|
|
31
|
+
const { classifySession, SESSION_TYPES } = require('../../modules/sessions/session-type');
|
|
32
|
+
const db = require('../../modules/sessions/db');
|
|
33
|
+
const { arg, hasFlag } = require('./cli-lib');
|
|
34
|
+
|
|
35
|
+
const BATCH = 500;
|
|
36
|
+
|
|
37
|
+
async function main() {
|
|
38
|
+
const apply = hasFlag('--apply');
|
|
39
|
+
const limitArg = Number(arg('--limit'));
|
|
40
|
+
const cap = Number.isFinite(limitArg) && limitArg > 0 ? Math.trunc(limitArg) : Infinity;
|
|
41
|
+
|
|
42
|
+
const tally = Object.fromEntries(SESSION_TYPES.map((t) => [t, 0]));
|
|
43
|
+
let scanned = 0;
|
|
44
|
+
let written = 0;
|
|
45
|
+
|
|
46
|
+
// Paged rather than one big SELECT: `detail` is a jsonb per row and the corpus
|
|
47
|
+
// is the largest table this module owns, so pulling it whole would hold the
|
|
48
|
+
// entire transcript corpus in memory at once for no gain.
|
|
49
|
+
for (;;) {
|
|
50
|
+
const room = Math.min(BATCH, cap - scanned);
|
|
51
|
+
if (room <= 0) break;
|
|
52
|
+
const rows = await db.unclassifiedSessions(room);
|
|
53
|
+
if (rows.length === 0) break;
|
|
54
|
+
for (const row of rows) {
|
|
55
|
+
const type = classifySession({ detail: row.detail, metrics: row.metrics });
|
|
56
|
+
tally[type] = (tally[type] || 0) + 1;
|
|
57
|
+
scanned++;
|
|
58
|
+
if (apply) written += await db.setSessionType(row.id, type);
|
|
59
|
+
}
|
|
60
|
+
// A dry run never writes, so the same unclassified page comes back forever.
|
|
61
|
+
// One page is enough to show the shape; say so rather than looping.
|
|
62
|
+
if (!apply) break;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
console.log(`\nsession-type backfill — ${apply ? 'APPLIED' : 'DRY RUN (no writes)'}`);
|
|
66
|
+
console.log(` rows classified ${scanned}`);
|
|
67
|
+
if (apply) console.log(` rows written ${written}`);
|
|
68
|
+
console.log(` distribution:`);
|
|
69
|
+
for (const t of SESSION_TYPES) {
|
|
70
|
+
const n = tally[t] || 0;
|
|
71
|
+
const pct = scanned ? ` (${Math.round((n / scanned) * 100)}%)` : '';
|
|
72
|
+
console.log(` ${t.padEnd(14)} ${String(n).padStart(6)}${pct}`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (apply) {
|
|
76
|
+
console.log(`\n corpus now:`);
|
|
77
|
+
for (const r of await db.sessionTypeCounts()) {
|
|
78
|
+
console.log(` ${String(r.type).padEnd(14)} ${String(r.n).padStart(6)}`);
|
|
79
|
+
}
|
|
80
|
+
} else if (scanned > 0) {
|
|
81
|
+
console.log(`\n dry run reads one page of ${BATCH}; re-run with --apply to write and walk the whole corpus.`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
main()
|
|
86
|
+
.then(() => process.exit(0))
|
|
87
|
+
.catch((err) => { console.error(err); process.exit(1); });
|
package/scripts/gds/fitness.js
CHANGED
|
@@ -200,9 +200,19 @@ function checkGeneratedArtifactsFresh() {
|
|
|
200
200
|
} catch {
|
|
201
201
|
violations.push('docs/api/openapi.json + docs/api-reference.md + docs/routes-permissions.md are STALE (or the spec is not valid OpenAPI 3.1) — run `node scripts/gds/gen-api-docs.js` and commit (task 1918).');
|
|
202
202
|
}
|
|
203
|
+
try {
|
|
204
|
+
// task 1003556: the copy registry's rows carry LINE NUMBERS, so editing any scanned
|
|
205
|
+
// UI file stales it. It was covered only by ship-check's OPTIONAL step, which a
|
|
206
|
+
// builder has to remember to run — so the first signal was CI failing on the PR's
|
|
207
|
+
// MERGE ref, where the shift is hardest to attribute. Gated here so a local fitness
|
|
208
|
+
// run says so first.
|
|
209
|
+
execFileSync(process.execPath, [path.join(__dirname, 'copy-inventory.js'), '--check'], { cwd: REPO_ROOT, stdio: 'pipe' });
|
|
210
|
+
} catch {
|
|
211
|
+
violations.push('docs/copy-registry.json + docs/copy-inventory.md are STALE — every row carries a line number, so editing any scanned UI file shifts them. Run `node scripts/gds/copy-inventory.js` and commit (task 1003556).');
|
|
212
|
+
}
|
|
203
213
|
// NB the role packs' responsibility blocks are generated too, but their freshness
|
|
204
214
|
// is asserted by role-pack-guard.js — one check owns "the packs are correct".
|
|
205
|
-
return { name: 'generated artifacts fresh', ok: violations.length === 0, hardFail: violations.length > 0, violations, warnings: [], note: 'Tracked generated content matches source: nested CLAUDE.md symbol blocks (code) + the CLAUDE.md §13 snippet (sessions) via --check-block, plus the CLAUDE.md §2 interaction-posture block (the knob defaults), docs/file-map.md skills/scheduled-tasks sections
|
|
215
|
+
return { name: 'generated artifacts fresh', ok: violations.length === 0, hardFail: violations.length > 0, violations, warnings: [], note: 'Tracked generated content matches source: nested CLAUDE.md symbol blocks (code) + the CLAUDE.md §13 snippet (sessions) via --check-block, plus the CLAUDE.md §2 interaction-posture block (the knob defaults), docs/file-map.md skills/scheduled-tasks sections, docs/api/openapi.json + api-reference.md (API) and docs/copy-registry.json + copy-inventory.md (the reader-facing copy, task 1003556). The gitignored docs/repo-map.md + docs/session-log-index.md are deploy-regenerated (ADR 0110), not gated here.' };
|
|
206
216
|
}
|
|
207
217
|
|
|
208
218
|
// ---- deterministic cron routine boundary (System 4 / ADR 0078) -------------
|
|
@@ -1223,6 +1233,7 @@ const CHECKS = [
|
|
|
1223
1233
|
// crashes later, so a start-up refusal would trade an outage risk for a cosmetic
|
|
1224
1234
|
// one (ADR 0289). Rationale + the seam→consumer table live in that file.
|
|
1225
1235
|
require('./seam-wiring-guard.js').checkSeamWiring,
|
|
1236
|
+
require('./home-isolation-guard.js').checkHomeIsolation, // Check 34 — task 1003763: a test that sets HOME for a child without USERPROFILE isolates NOTHING on win32, so it silently stops testing what its comment says. Three tasks fixed this one site at a time. Rationale in that file.
|
|
1226
1237
|
require('./adr-namespace.js').checkAdrNumbering, require('./exec-path-guard.js').checkExecPathsStatic, // Check 32 — task 1003371 / B50: what the server EXECUTES, out of whose tree — the blind spot all three 2026-08-29 CRITICALs shared. Rationale + reviewed exemptions live in that file. Sharing Check 31's line (the 19c/19d + 23/24 precedent above): fitness.js is AT the 1,500 ratchet and a new check cannot afford a line until task 1003825 buys it back.
|
|
1227
1238
|
];
|
|
1228
1239
|
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// scripts/gds/home-isolation-guard.js — setting HOME for a child process isolates
|
|
4
|
+
// nothing on Windows (task 1003763).
|
|
5
|
+
//
|
|
6
|
+
// THE DEFECT, three times now. os.homedir() reads USERPROFILE on win32 and never
|
|
7
|
+
// consults HOME, so `env: { ...process.env, HOME: tmp }` leaves the child looking at
|
|
8
|
+
// the DEVELOPER'S REAL home — and therefore their real CLI session.
|
|
9
|
+
//
|
|
10
|
+
// tests/cli_sessions.mjs (task 1003760) — DESTROYED the developer's live session.
|
|
11
|
+
// tests/cli_surface.mjs (task 1003941) — guidance printers took the authenticated
|
|
12
|
+
// branch, so the test failed for a false
|
|
13
|
+
// reason on any signed-in Windows box.
|
|
14
|
+
// tests/cli_package.mjs (task 1003763) — an unauthenticated-path assertion could
|
|
15
|
+
// pass because the machine was signed in.
|
|
16
|
+
//
|
|
17
|
+
// The last two are the dangerous shape: a test that isolates nothing does not
|
|
18
|
+
// announce itself, it just quietly stops testing what its comment says it tests.
|
|
19
|
+
//
|
|
20
|
+
// WHY A GUARD AND NOT THREE FIXES. Three sites, three separate tasks, each found by
|
|
21
|
+
// accident. tests/memory_pull.mjs, tests/redteam_patrol.mjs and
|
|
22
|
+
// tests/setup_device_flow_fallback.mjs already do it correctly, so the convention
|
|
23
|
+
// exists and is simply easy to miss — which is the definition of something CI should
|
|
24
|
+
// hold rather than a reviewer.
|
|
25
|
+
|
|
26
|
+
const fs = require('node:fs');
|
|
27
|
+
const path = require('node:path');
|
|
28
|
+
|
|
29
|
+
const REPO_ROOT = path.resolve(__dirname, '..', '..');
|
|
30
|
+
const TESTS_DIR = path.join(REPO_ROOT, 'tests');
|
|
31
|
+
|
|
32
|
+
// WHAT COUNTS AS A SPAWN ENV. Only a literal that SPREADS process.env and assigns
|
|
33
|
+
// HOME — that is the shape every real defect had. Two narrowings, both learned by
|
|
34
|
+
// getting this wrong on the first run against the tree:
|
|
35
|
+
//
|
|
36
|
+
// - COMMENTS ARE STRIPPED. All three fixed files now explain the bug by QUOTING
|
|
37
|
+
// the broken line, so a raw text scan reports the very files that fixed it.
|
|
38
|
+
// - A HELPER'S CALL SITE IS NOT A SPAWN ENV. tests/setup_smoke.mjs passes
|
|
39
|
+
// `{ HOME: fakeHome }` to its own runSetup(), which adds USERPROFILE itself.
|
|
40
|
+
// Requiring `...process.env` in the same literal distinguishes 'building a
|
|
41
|
+
// child's environment' from 'passing an option to a function', and flags the
|
|
42
|
+
// helper rather than each of its callers — which is where the fix belongs.
|
|
43
|
+
const HOME_ASSIGN_RE = /(^|[{,\s])HOME\s*:/;
|
|
44
|
+
const USERPROFILE_RE = /(^|[{,\s])USERPROFILE\s*:/;
|
|
45
|
+
const PROCESS_ENV_RE = /\.\.\.process\.env/;
|
|
46
|
+
|
|
47
|
+
// Strip line and block comments so prose ABOUT the bug is not read as the bug.
|
|
48
|
+
// Errs toward keeping text (a guard should fail loud, not quiet): only the two
|
|
49
|
+
// unambiguous comment forms are removed.
|
|
50
|
+
function stripComments(src) {
|
|
51
|
+
return String(src || '')
|
|
52
|
+
.replace(/\/\*[\s\S]*?\*\//g, '')
|
|
53
|
+
.replace(/(^|[^:])\/\/.*$/gm, '$1');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Files where HOME is the SUBJECT rather than an isolation mechanism, named in task
|
|
57
|
+
// 1003763. An allowlist entry is a claim that the file means something else by HOME,
|
|
58
|
+
// so each one says which.
|
|
59
|
+
const ALLOW = new Map([
|
|
60
|
+
['scrubber_corpus.mjs', "HOME:'/root' is literal corpus DATA — a string the scrubber is asked to redact, not an env handed to a child"],
|
|
61
|
+
['lib_sh_resolution.mjs', 'exercises POSIX shell HOME resolution; HOME is the thing under test, and a win32 variable would change the subject'],
|
|
62
|
+
['home_isolation_guard.mjs', "this guard's OWN tests carry the broken shape as fixture strings — it must not report its own corpus, the same way category-advisory-guard.js had to stop matching its own filename"],
|
|
63
|
+
]);
|
|
64
|
+
|
|
65
|
+
// PURE — the lines that build a child env with HOME and no USERPROFILE. Exported so
|
|
66
|
+
// the matching is testable without the tree: a scanner whose pattern silently stops
|
|
67
|
+
// matching reports a clean repo, which is the failure mode this whole file is about.
|
|
68
|
+
function offendingLines(src) {
|
|
69
|
+
const lines = stripComments(src).split('\n');
|
|
70
|
+
const out = [];
|
|
71
|
+
for (let i = 0; i < lines.length; i++) {
|
|
72
|
+
if (!HOME_ASSIGN_RE.test(lines[i])) continue;
|
|
73
|
+
// A literal can wrap: read a small window either side for its companions.
|
|
74
|
+
const window = lines.slice(Math.max(0, i - 4), i + 5).join('\n');
|
|
75
|
+
if (!PROCESS_ENV_RE.test(window)) continue; // an option object, not a child env
|
|
76
|
+
if (USERPROFILE_RE.test(window)) continue; // already isolated properly
|
|
77
|
+
out.push({ line: i + 1, text: lines[i].trim() });
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
function checkHomeIsolation() {
|
|
82
|
+
const name = 'a child env that sets HOME also sets USERPROFILE (task 1003763)';
|
|
83
|
+
let names = [];
|
|
84
|
+
try {
|
|
85
|
+
names = fs.readdirSync(TESTS_DIR).filter((f) => f.endsWith('.mjs'));
|
|
86
|
+
} catch (e) {
|
|
87
|
+
return { name, ok: true, hardFail: false, violations: [], warnings: [`tests/ unreadable: ${e.message}`], note: 'skipped.' };
|
|
88
|
+
}
|
|
89
|
+
// The scan must not pass by finding nothing.
|
|
90
|
+
if (names.length === 0) {
|
|
91
|
+
return {
|
|
92
|
+
name, ok: false, hardFail: true,
|
|
93
|
+
violations: ['no tests/*.mjs found at all — that is a broken scan, not a clean one'],
|
|
94
|
+
warnings: [], note: 'the scan produced an empty file set.',
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const violations = [];
|
|
99
|
+
let scanned = 0;
|
|
100
|
+
let sitesSeen = 0;
|
|
101
|
+
for (const n of names) {
|
|
102
|
+
const src = fs.readFileSync(path.join(TESTS_DIR, n), 'utf8');
|
|
103
|
+
scanned += 1;
|
|
104
|
+
const hits = offendingLines(src);
|
|
105
|
+
if (HOME_ASSIGN_RE.test(stripComments(src)) && PROCESS_ENV_RE.test(stripComments(src))) sitesSeen += 1;
|
|
106
|
+
if (ALLOW.has(n)) continue;
|
|
107
|
+
for (const h of hits) {
|
|
108
|
+
violations.push(`tests/${n}:${h.line} sets HOME for a child without USERPROFILE — \`${h.text.slice(0, 90)}\`. `
|
|
109
|
+
+ 'On Windows os.homedir() reads USERPROFILE and ignores HOME, so this isolates NOTHING: the child sees the '
|
|
110
|
+
+ 'developer\'s real home and real session, and an unauthenticated-path assertion can pass because the machine '
|
|
111
|
+
+ 'happens to be signed in. Set USERPROFILE (and HOMEDRIVE/HOMEPATH) to the same directory.');
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
name,
|
|
117
|
+
ok: violations.length === 0,
|
|
118
|
+
hardFail: violations.length > 0,
|
|
119
|
+
violations,
|
|
120
|
+
warnings: [],
|
|
121
|
+
note: `${scanned} test file(s) scanned, ${sitesSeen} of them build a child env with HOME; ${ALLOW.size} allowlisted where HOME is the subject rather than the isolation. `
|
|
122
|
+
+ 'Three separate tasks (1003760, 1003941, 1003763) fixed this one site at a time before it was worth a check.',
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
module.exports = { checkHomeIsolation, offendingLines, stripComments, ALLOW, HOME_ASSIGN_RE, USERPROFILE_RE, PROCESS_ENV_RE };
|
|
@@ -27,7 +27,7 @@ const {
|
|
|
27
27
|
} = require('./ship-land.js');
|
|
28
28
|
const { runSmoke } = require('./ship-grade.js');
|
|
29
29
|
const { dbOnlyIgnoredNotice } = require('./ship-honesty.js');
|
|
30
|
-
const { currentBranch, mainWorktreeBusy, regenerateApiClient, regenerateApiDocs, regenerateDiagrams, regenerateFileMap, regenerateRepoMap, regenerateSessionIndex } = require('./ship-regen.js');
|
|
30
|
+
const { currentBranch, mainWorktreeBusy, regenerateApiClient, regenerateApiDocs, regenerateCopyInventory, regenerateDiagrams, regenerateFileMap, regenerateRepoMap, regenerateSessionIndex } = require('./ship-regen.js');
|
|
31
31
|
const { REPO_ROOT } = require('./ship-state.js');
|
|
32
32
|
|
|
33
33
|
// zeroCommitVerdict — dbOnly is deliberately consulted ONLY at zero commits
|
|
@@ -119,6 +119,10 @@ async function mergeLocally(taskId, valueSummary, { dbOnly = false } = {}) {
|
|
|
119
119
|
regenerateSessionIndex();
|
|
120
120
|
// Refresh the generated file-map sections (skills + scheduled-tasks) on the same beat (ADR 0066 / task 1276).
|
|
121
121
|
regenerateFileMap();
|
|
122
|
+
// Refresh the copy registry + inventory on the same beat (task 1003556). Its rows
|
|
123
|
+
// carry line numbers, so ANY edit to a scanned UI file staled it — and nothing in
|
|
124
|
+
// the ship path regenerated it, so the PR was born stale and CI said so.
|
|
125
|
+
regenerateCopyInventory();
|
|
122
126
|
// Refresh the generated OpenAPI spec + API reference from the shipped route tree (task 1918).
|
|
123
127
|
regenerateApiDocs();
|
|
124
128
|
// ...then the typed client FROM that spec (+ the devbox vendored copy) on the same beat (task 2051).
|