@bongos/core 1.19.273 → 1.19.274
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 +22 -12
- package/modules/lifecycle/est-advisory.js +162 -0
- package/modules/lifecycle/routes/tasks.js +2 -2
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +3 -1
- package/tests/est_advisory.mjs +183 -0
- package/tests/goal_advisory.mjs +6 -1
package/.bongos-core.json
CHANGED
|
@@ -2,22 +2,22 @@
|
|
|
2
2
|
"artifact": "bongos-core",
|
|
3
3
|
"manifest_schema": 1,
|
|
4
4
|
"generator": "scripts/gds/package-core.js",
|
|
5
|
-
"core_version": "1.19.
|
|
6
|
-
"core_contract": "1.19.
|
|
7
|
-
"source_commit": "
|
|
5
|
+
"core_version": "1.19.274",
|
|
6
|
+
"core_contract": "1.19.274",
|
|
7
|
+
"source_commit": "ec41b43ba6a9b1e32db2d3e4b5eeac6361fdb290",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-08-
|
|
9
|
+
"built_at": "2026-08-21T14:03:13.782Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 318,
|
|
13
13
|
"agent_docs_stubbed": 22,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 1372,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 1712,
|
|
20
|
+
"tree_sha256": "55fc345705cf2b709b62edc5c43cc5a18b8ff6b3641f592a9cf5183904f8db3b",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/blocker-review/SKILL.md",
|
|
@@ -3749,6 +3749,11 @@
|
|
|
3749
3749
|
"mode": "0000644",
|
|
3750
3750
|
"sha256": "4e6f6af1cdaf026466155c1b0656a0e89872aa2bb264f36c50997ec4f069e808"
|
|
3751
3751
|
},
|
|
3752
|
+
{
|
|
3753
|
+
"path": "modules/lifecycle/est-advisory.js",
|
|
3754
|
+
"mode": "0000644",
|
|
3755
|
+
"sha256": "23ee20e57c67adb5d2ec29c654d2bc469434cafa010b375a8a4452ced1df49e3"
|
|
3756
|
+
},
|
|
3752
3757
|
{
|
|
3753
3758
|
"path": "modules/lifecycle/github-push.js",
|
|
3754
3759
|
"mode": "0000644",
|
|
@@ -3847,7 +3852,7 @@
|
|
|
3847
3852
|
{
|
|
3848
3853
|
"path": "modules/lifecycle/routes/tasks.js",
|
|
3849
3854
|
"mode": "0000644",
|
|
3850
|
-
"sha256": "
|
|
3855
|
+
"sha256": "0600b76953e28b2c5828301606c72e2490756d43a2eb74b4bc92dca9d795cb77"
|
|
3851
3856
|
},
|
|
3852
3857
|
{
|
|
3853
3858
|
"path": "modules/lifecycle/routes/versions.js",
|
|
@@ -4472,12 +4477,12 @@
|
|
|
4472
4477
|
{
|
|
4473
4478
|
"path": "package-lock.json",
|
|
4474
4479
|
"mode": "0000644",
|
|
4475
|
-
"sha256": "
|
|
4480
|
+
"sha256": "2aa1f6741a57aabd4570045588e71afbc04bd100b19fefed328dad581d9bb4f6"
|
|
4476
4481
|
},
|
|
4477
4482
|
{
|
|
4478
4483
|
"path": "package.json",
|
|
4479
4484
|
"mode": "0000644",
|
|
4480
|
-
"sha256": "
|
|
4485
|
+
"sha256": "edcbca0acb725c24414c2234cc1063d98913867b5ecb0f659e1b58e61e1d6a42"
|
|
4481
4486
|
},
|
|
4482
4487
|
{
|
|
4483
4488
|
"path": "public-docs/index.html",
|
|
@@ -6122,7 +6127,7 @@
|
|
|
6122
6127
|
{
|
|
6123
6128
|
"path": "src/module-api.js",
|
|
6124
6129
|
"mode": "0000644",
|
|
6125
|
-
"sha256": "
|
|
6130
|
+
"sha256": "f94e654fd2e4491e44dcec5ac26e4bf97f9a430bdade551f6fd243d3dee005ef"
|
|
6126
6131
|
},
|
|
6127
6132
|
{
|
|
6128
6133
|
"path": "src/module-loader/catalog.js",
|
|
@@ -6904,6 +6909,11 @@
|
|
|
6904
6909
|
"mode": "0000644",
|
|
6905
6910
|
"sha256": "89e05751271dd18b96a2319d624403353bdc710b2e8b5c0cf6181b125cd17b76"
|
|
6906
6911
|
},
|
|
6912
|
+
{
|
|
6913
|
+
"path": "tests/est_advisory.mjs",
|
|
6914
|
+
"mode": "0000644",
|
|
6915
|
+
"sha256": "171f5ed03eecea4502fddbaa5c12377c1a923aa63c3e3242ad47588d63a23f1a"
|
|
6916
|
+
},
|
|
6907
6917
|
{
|
|
6908
6918
|
"path": "tests/event_sound_prefs.mjs",
|
|
6909
6919
|
"mode": "0000644",
|
|
@@ -7027,7 +7037,7 @@
|
|
|
7027
7037
|
{
|
|
7028
7038
|
"path": "tests/goal_advisory.mjs",
|
|
7029
7039
|
"mode": "0000644",
|
|
7030
|
-
"sha256": "
|
|
7040
|
+
"sha256": "f5a6985502bc85589d688bf96b3e9242d84efec39d52f5a451e92c23cbb9175c"
|
|
7031
7041
|
},
|
|
7032
7042
|
{
|
|
7033
7043
|
"path": "tests/goal_cli.mjs",
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// modules/lifecycle/est-advisory.js — the ESTIMATE-SCALE advisory (task 1003148).
|
|
2
|
+
//
|
|
3
|
+
// THE HOLE. Nothing tells a task author that their estimate is on the wrong SCALE.
|
|
4
|
+
// Across 1,173 shipped tasks carrying both est_minutes and actual_minutes, the
|
|
5
|
+
// median actual/est ratio is 0.100 — and it is uniform across every kind with a
|
|
6
|
+
// real sample (feature 0.078/n=489, bug 0.150/n=214, infra 0.097/n=144,
|
|
7
|
+
// cleanup 0.133/n=89, decision 0.100/n=76, refactor 0.120/n=62, spike 0.142/n=36).
|
|
8
|
+
// That is not per-task noise. Authors write HUMAN-HOURS-shaped numbers for work an
|
|
9
|
+
// agent does in minutes.
|
|
10
|
+
//
|
|
11
|
+
// pms_calibrated_minutes (migration 015) already divides by that median so
|
|
12
|
+
// /builder-start and the optimizer show a sane figure. But the STORED number stays
|
|
13
|
+
// wrong, nobody is ever told, and every newly-filed task re-adds the same error —
|
|
14
|
+
// so the calibration is a permanent correction for a mistake with no feedback
|
|
15
|
+
// loop. It also misleads humans directly: GET /tasks/:id leads with the raw field,
|
|
16
|
+
// and a session quoted task 1002572 to the owner as "60 min" when its calibrated
|
|
17
|
+
// value is 9.
|
|
18
|
+
//
|
|
19
|
+
// This module closes the loop WITHOUT blocking anything: POST /tasks attaches the
|
|
20
|
+
// verdict to its 201 body as `est_advisory`, and an in-scale estimate gets `null` —
|
|
21
|
+
// silence. Deliberately NOT an auto-correction: silently rewriting the author's
|
|
22
|
+
// number is worse than the current silence, because then nobody learns the scale
|
|
23
|
+
// either. Show the figure, name the evidence, let the author decide.
|
|
24
|
+
//
|
|
25
|
+
// PURE core + FAIL-OPEN wrapper, exported so every branch is pinned by a DB-free
|
|
26
|
+
// unit test. A module sibling, like ./goal-advisory.js — same contract.
|
|
27
|
+
|
|
28
|
+
// WHY THIS IS AN ABSOLUTE DELTA AND NOT A RATIO — a design flaw my own test
|
|
29
|
+
// caught, worth recording so it is not reintroduced. The calibration is a pure
|
|
30
|
+
// scalar multiply, so submitted / (submitted x ratio) === 1 / ratio for EVERY
|
|
31
|
+
// input. A ratio-based "how inflated is this?" test therefore returns the same
|
|
32
|
+
// answer for a correct 9-minute estimate as for a wrong 600-minute one — it fires
|
|
33
|
+
// on everything, which is the noise failure this advisory must avoid. (It did:
|
|
34
|
+
// est=9 for a bug floored to calibrated=1 and read as "9x over".)
|
|
35
|
+
//
|
|
36
|
+
// So the trigger is whether the TRANSLATION IS MATERIAL: how many minutes the
|
|
37
|
+
// author's number and the calibrated equivalent actually differ by. A 9-minute
|
|
38
|
+
// estimate translates to ~1 (delta 8 — not worth a word); a 300-minute estimate
|
|
39
|
+
// translates to 45 (delta 255 — worth saying out loud).
|
|
40
|
+
//
|
|
41
|
+
// AND BE PRECISE ABOUT THE FEEDBACK LOOP, because the tempting claim is false:
|
|
42
|
+
// adopting the suggested value does NOT silence this for that task. The
|
|
43
|
+
// calibration is a constant multiply, so the new number gets translated too. The
|
|
44
|
+
// loop closes at the POPULATION level — pms_calibration_summary recomputes the
|
|
45
|
+
// median from shipped rows, so as authors write agent-minutes the ratio itself
|
|
46
|
+
// climbs toward 1.0 and the delta collapses for everyone at once. That is the
|
|
47
|
+
// only mechanism that can actually retire this advisory, and it is why the
|
|
48
|
+
// advisory reports the ratio and its sample size rather than a verdict.
|
|
49
|
+
const MIN_MATERIAL_DELTA = 15;
|
|
50
|
+
|
|
51
|
+
// TWO conditions, because the delta alone is partly a "large estimate" detector:
|
|
52
|
+
// with a mostly-corrected population (ratio 0.9) a 300-minute estimate still
|
|
53
|
+
// differs by 30. This advisory is about SCALE, so it also requires the scale to
|
|
54
|
+
// actually be off. Above this ratio, estimates and actuals broadly agree and there
|
|
55
|
+
// is nothing to translate no matter how big the number is — which is what lets the
|
|
56
|
+
// population feedback loop retire the advisory outright.
|
|
57
|
+
const SCALE_OK_RATIO = 0.5;
|
|
58
|
+
|
|
59
|
+
// Below this many samples for a kind, that kind's median is not worth quoting and
|
|
60
|
+
// the overall median is used instead — mirroring pms_calibrated_minutes' own
|
|
61
|
+
// thresholds (>=5 per kind, else >=10 overall) so the advisory and the calibration
|
|
62
|
+
// can never disagree about which number applies.
|
|
63
|
+
const MIN_KIND_SAMPLES = 5;
|
|
64
|
+
const MIN_OVERALL_SAMPLES = 10;
|
|
65
|
+
|
|
66
|
+
// PURE. Pick the median ratio that governs this kind, and say which population it
|
|
67
|
+
// came from — the author should be able to tell "your kind, 214 samples" from
|
|
68
|
+
// "everything, 1173 samples".
|
|
69
|
+
function pickRatio({ kind, summary }) {
|
|
70
|
+
const rows = Array.isArray(summary) ? summary : [];
|
|
71
|
+
const forKind = rows.find((r) => r && r.kind === kind);
|
|
72
|
+
if (forKind && Number(forKind.sample_size) >= MIN_KIND_SAMPLES) {
|
|
73
|
+
const ratio = Number(forKind.median_ratio);
|
|
74
|
+
if (Number.isFinite(ratio) && ratio > 0) {
|
|
75
|
+
return { ratio, sample_size: Number(forKind.sample_size), basis: kind };
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
// Overall: a sample-weighted median is not reconstructible from per-kind medians,
|
|
79
|
+
// so use the median OF the per-kind medians weighted by nothing — an honest
|
|
80
|
+
// approximation, labelled as such. Callers that need the exact overall figure
|
|
81
|
+
// should read pms_calibration_summary() directly.
|
|
82
|
+
const usable = rows
|
|
83
|
+
.map((r) => ({ ratio: Number(r && r.median_ratio), n: Number(r && r.sample_size) }))
|
|
84
|
+
.filter((r) => Number.isFinite(r.ratio) && r.ratio > 0 && Number.isFinite(r.n) && r.n > 0);
|
|
85
|
+
const total = usable.reduce((s, r) => s + r.n, 0);
|
|
86
|
+
if (total < MIN_OVERALL_SAMPLES || usable.length === 0) return null;
|
|
87
|
+
const sorted = usable.map((r) => r.ratio).sort((a, b) => a - b);
|
|
88
|
+
const mid = Math.floor(sorted.length / 2);
|
|
89
|
+
const ratio = sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
|
|
90
|
+
return { ratio, sample_size: total, basis: 'all kinds' };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// PURE, FAIL-OPEN. Returns null when there is nothing to say: no estimate given,
|
|
94
|
+
// no usable history, or an estimate already in the right scale.
|
|
95
|
+
function adviseEstimate({ estMinutes, kind, summary, taskId = null }) {
|
|
96
|
+
const est = Number(estMinutes);
|
|
97
|
+
if (!Number.isFinite(est) || est <= 0) return null; // nothing to judge
|
|
98
|
+
const picked = pickRatio({ kind, summary });
|
|
99
|
+
if (!picked) return null; // not enough history to make a claim
|
|
100
|
+
if (picked.ratio >= SCALE_OK_RATIO) return null; // the scale is fine — nothing to say
|
|
101
|
+
const calibrated = Math.max(1, Math.round(est * picked.ratio));
|
|
102
|
+
if (est - calibrated < MIN_MATERIAL_DELTA) return null; // translation immaterial — say nothing
|
|
103
|
+
const ratio = Math.round(picked.ratio * 1000) / 1000;
|
|
104
|
+
// The historical factor is a property of the KIND, not of this estimate — see
|
|
105
|
+
// the note on MIN_MATERIAL_DELTA. Report it as such rather than implying we
|
|
106
|
+
// measured this particular guess.
|
|
107
|
+
const kindFactor = Math.round((1 / picked.ratio) * 10) / 10;
|
|
108
|
+
return {
|
|
109
|
+
submitted_minutes: est,
|
|
110
|
+
calibrated_minutes: calibrated,
|
|
111
|
+
kind_factor: kindFactor,
|
|
112
|
+
median_ratio: ratio,
|
|
113
|
+
sample_size: picked.sample_size,
|
|
114
|
+
basis: picked.basis,
|
|
115
|
+
message: `est_minutes=${est} translates to ~${calibrated} min of real time. Across `
|
|
116
|
+
+ `${picked.sample_size} shipped task(s) (${picked.basis}) the median actual/est ratio is `
|
|
117
|
+
+ `${ratio}, i.e. estimates of this kind have run ~${kindFactor}x long — est_minutes is `
|
|
118
|
+
+ 'AGENT-minutes, not human-hours. The calibration corrects the board, but the STORED number '
|
|
119
|
+
+ 'is what a reader sees.',
|
|
120
|
+
next_step: taskId
|
|
121
|
+
? `If ${calibrated} is closer, correct it: PATCH /tasks/${taskId} {"est_minutes": ${calibrated}}`
|
|
122
|
+
: `Consider est_minutes: ${calibrated}.`,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// The calibration population. Reads pms_calibration_summary() — the transparency
|
|
127
|
+
// companion migration 015 shipped for exactly this — rather than recomputing the
|
|
128
|
+
// medians, so this and pms_calibrated_minutes can never disagree about the ratio.
|
|
129
|
+
// Lives HERE rather than in db.js: db.js is over the size ratchet's cap and this
|
|
130
|
+
// is the only caller. The pool comes through the published module doorway.
|
|
131
|
+
// Deliberately not fail-open — buildEstAdvisory below is the single failure site.
|
|
132
|
+
async function calibrationSummary({ pool } = {}) {
|
|
133
|
+
const p = pool || require('../../src/module-api').getPool();
|
|
134
|
+
const { rows } = await p.query('SELECT kind, median_ratio, sample_size FROM pms_calibration_summary()');
|
|
135
|
+
return rows || [];
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// The DB-touching wrapper. NON-BLOCKING BY CONSTRUCTION — any failure yields null
|
|
139
|
+
// and the create still returns 201. Same contract as buildGoalAdvisory. `db` is
|
|
140
|
+
// accepted for symmetry with that function and to let a test inject a summary.
|
|
141
|
+
async function buildEstAdvisory({ db, task } = {}) {
|
|
142
|
+
try {
|
|
143
|
+
if (!task || task.est_minutes == null) return null;
|
|
144
|
+
const summary = (db && db.calibrationSummary) ? await db.calibrationSummary() : await calibrationSummary();
|
|
145
|
+
return adviseEstimate({
|
|
146
|
+
estMinutes: task.est_minutes,
|
|
147
|
+
kind: task.kind,
|
|
148
|
+
summary,
|
|
149
|
+
taskId: task.id,
|
|
150
|
+
});
|
|
151
|
+
} catch (e) {
|
|
152
|
+
// Through the published logger seam (api.logger), not a raw console call: a
|
|
153
|
+
// module logs via the doorway, and the console_call_count ratchet
|
|
154
|
+
// (config/fitness-baselines.json) holds at its recorded floor. Lazy-required
|
|
155
|
+
// so the DB-free unit test that exercises THIS branch stays hermetic.
|
|
156
|
+
require('../../src/module-api').logger('lifecycle')
|
|
157
|
+
.error('est-advisory (non-blocking)', e && e.message);
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
module.exports = { adviseEstimate, pickRatio, buildEstAdvisory, calibrationSummary, MIN_MATERIAL_DELTA, SCALE_OK_RATIO, MIN_KIND_SAMPLES, MIN_OVERALL_SAMPLES };
|
|
@@ -560,7 +560,7 @@ module.exports = function buildTasksRouter() {
|
|
|
560
560
|
description: { type: 'string', maxLength: LIMITS.DESCRIPTION },
|
|
561
561
|
status: { type: 'string', maxLength: 32 },
|
|
562
562
|
touches: { type: 'array', maxItems: 500, itemsType: 'string' },
|
|
563
|
-
est_minutes: { type: 'integer', min: 0, max: 100000 },
|
|
563
|
+
est_minutes: { type: 'integer', min: 0, max: 100000 }, // AGENT-minutes — see est-advisory.js (task 1003148)
|
|
564
564
|
est_cost_usd: { type: 'number', min: 0 },
|
|
565
565
|
manual_degree: { type: 'integer', min: TASK_FIELD_BOUNDS.manual_degree.min, max: TASK_FIELD_BOUNDS.manual_degree.max },
|
|
566
566
|
priority: { type: 'integer', min: TASK_FIELD_BOUNDS.priority.min, max: TASK_FIELD_BOUNDS.priority.max },
|
|
@@ -757,7 +757,7 @@ module.exports = function buildTasksRouter() {
|
|
|
757
757
|
needs_migration: needsMigration,
|
|
758
758
|
criteria,
|
|
759
759
|
suggested_criteria,
|
|
760
|
-
goal_advisory,
|
|
760
|
+
goal_advisory, est_advisory: await require('../est-advisory').buildEstAdvisory({ db, task }), // task 1003148
|
|
761
761
|
});
|
|
762
762
|
} catch (err) {
|
|
763
763
|
console.error('[gds] POST /tasks', err);
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.274",
|
|
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.274",
|
|
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.274",
|
|
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",
|
package/src/module-api.js
CHANGED
|
@@ -973,8 +973,10 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
973
973
|
// landed since 1.19.271 with no explicit bump. run 32438140524. (task 1002620)
|
|
974
974
|
// 1.19.273 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
975
975
|
// landed since 1.19.272 with no explicit bump. run 32441024959. (task 1002620)
|
|
976
|
+
// 1.19.274 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
977
|
+
// landed since 1.19.273 with no explicit bump. run 32490024209. (task 1002620)
|
|
976
978
|
// ---------------------------------------------------------------------------
|
|
977
|
-
const CORE_VERSION = '1.19.
|
|
979
|
+
const CORE_VERSION = '1.19.274'; // CI auto-patch carrier (ADR 0161); see changelog above
|
|
978
980
|
|
|
979
981
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
980
982
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
// tests/est_advisory.mjs — the estimate-SCALE advisory (task 1003148).
|
|
2
|
+
//
|
|
3
|
+
// THE HOLE. Nothing tells a task author their estimate is on the wrong SCALE.
|
|
4
|
+
// Across 1,173 shipped tasks carrying both est_minutes and actual_minutes the
|
|
5
|
+
// median actual/est ratio is 0.100, uniform across every kind with a real sample
|
|
6
|
+
// (feature 0.078/n=489, bug 0.150/n=214, infra 0.097/n=144, cleanup 0.133/n=89).
|
|
7
|
+
// Authors write human-hours-shaped numbers for work an agent does in minutes.
|
|
8
|
+
//
|
|
9
|
+
// pms_calibrated_minutes (migration 015) already divides by that median, so the
|
|
10
|
+
// board looks sane while the STORED number stays wrong and every new task re-adds
|
|
11
|
+
// the error — a permanent correction with no feedback loop. It misleads humans
|
|
12
|
+
// directly too: GET /tasks/:id leads with the raw field, and a session quoted task
|
|
13
|
+
// 1002572 to the owner as "60 min" when its calibrated value is 9.
|
|
14
|
+
//
|
|
15
|
+
// What we prove (PURE — no DB, no network):
|
|
16
|
+
// 1. An out-of-scale estimate is flagged, with the calibrated equivalent, the
|
|
17
|
+
// ratio, and the SAMPLE SIZE it rests on — evidence, not an assertion.
|
|
18
|
+
// 2. An in-scale estimate returns null. Silence is the common case; an advisory
|
|
19
|
+
// that fires on every create is noise and gets ignored.
|
|
20
|
+
// 3. It never AUTO-CORRECTS. The submitted value is reported, not replaced —
|
|
21
|
+
// silently rewriting the author's number is worse than the current silence,
|
|
22
|
+
// because then nobody learns the scale either.
|
|
23
|
+
// 4. The ratio source mirrors pms_calibrated_minutes' own thresholds (>=5 for a
|
|
24
|
+
// kind, else the overall population), so advisory and calibration can never
|
|
25
|
+
// disagree about which number applies — and it says WHICH basis it used.
|
|
26
|
+
// 5. Missing / hostile / thin-sample input yields null, never a guess.
|
|
27
|
+
//
|
|
28
|
+
// Run: node tests/est_advisory.mjs
|
|
29
|
+
|
|
30
|
+
import { strict as assert } from 'node:assert';
|
|
31
|
+
import fs from 'node:fs';
|
|
32
|
+
import { createRequire } from 'node:module';
|
|
33
|
+
|
|
34
|
+
const require = createRequire(import.meta.url);
|
|
35
|
+
const { adviseEstimate, pickRatio, buildEstAdvisory, MIN_MATERIAL_DELTA, SCALE_OK_RATIO } =
|
|
36
|
+
require('../modules/lifecycle/est-advisory.js');
|
|
37
|
+
|
|
38
|
+
let passed = 0;
|
|
39
|
+
let failed = 0;
|
|
40
|
+
function t(name, fn) {
|
|
41
|
+
try { const r = fn(); if (r && r.then) return r.then(
|
|
42
|
+
() => { passed++; console.log(` ok ${name}`); },
|
|
43
|
+
(e) => { failed++; console.log(` FAIL ${name}`); console.log(` ${e.message}`); });
|
|
44
|
+
passed++; console.log(` ok ${name}`);
|
|
45
|
+
} catch (e) { failed++; console.log(` FAIL ${name}`); console.log(` ${e.message}`); }
|
|
46
|
+
return Promise.resolve();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// The real shape pms_calibration_summary() returns, with the real medians.
|
|
50
|
+
const SUMMARY = [
|
|
51
|
+
{ kind: 'feature', median_ratio: '0.0780', sample_size: 489 },
|
|
52
|
+
{ kind: 'bug', median_ratio: '0.1500', sample_size: 214 },
|
|
53
|
+
{ kind: 'infra', median_ratio: '0.0970', sample_size: 144 },
|
|
54
|
+
{ kind: 'cleanup', median_ratio: '0.1330', sample_size: 89 },
|
|
55
|
+
{ kind: 'blocker-resolution', median_ratio: '0.1280', sample_size: 2 }, // below the kind floor
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
// ---- 1. the flag, with its evidence ----
|
|
59
|
+
|
|
60
|
+
await t('an out-of-scale bug estimate is flagged with the calibrated equivalent', () => {
|
|
61
|
+
const a = adviseEstimate({ estMinutes: 60, kind: 'bug', summary: SUMMARY, taskId: 1002572 });
|
|
62
|
+
assert.ok(a, 'must advise');
|
|
63
|
+
assert.equal(a.submitted_minutes, 60);
|
|
64
|
+
assert.equal(a.calibrated_minutes, 9, '60 * 0.15 = 9 — the real number for task 1002572');
|
|
65
|
+
assert.equal(a.basis, 'bug');
|
|
66
|
+
assert.equal(a.sample_size, 214, 'quotes the sample the claim rests on');
|
|
67
|
+
assert.equal(a.median_ratio, 0.15);
|
|
68
|
+
assert.equal(a.kind_factor, 6.7, 'the historical factor is a property of the KIND');
|
|
69
|
+
assert.match(a.message, /AGENT-minutes/, 'names the unit, which is the actual defect');
|
|
70
|
+
assert.match(a.next_step, /PATCH \/tasks\/1002572 \{"est_minutes": 9\}/, 'gives the exact fix');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
await t('the advisory reports, never auto-corrects', () => {
|
|
74
|
+
const a = adviseEstimate({ estMinutes: 300, kind: 'bug', summary: SUMMARY, taskId: 5 });
|
|
75
|
+
assert.equal(a.submitted_minutes, 300, 'the submitted value is preserved verbatim');
|
|
76
|
+
assert.equal(a.calibrated_minutes, 45);
|
|
77
|
+
// Nothing in the advisory mutates anything — it is a pure verdict object.
|
|
78
|
+
assert.equal(typeof a, 'object');
|
|
79
|
+
assert.ok(!('task' in a), 'no task payload — it cannot rewrite the row');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// ---- 2. silence is the common case ----
|
|
83
|
+
|
|
84
|
+
await t('an already-agent-scale estimate returns null (no noise on a good create)', () => {
|
|
85
|
+
// The trigger is a MATERIAL translation, not a ratio — a ratio is constant by
|
|
86
|
+
// construction (see MIN_MATERIAL_DELTA) and would fire on correct estimates too.
|
|
87
|
+
assert.equal(adviseEstimate({ estMinutes: 9, kind: 'bug', summary: SUMMARY }), null,
|
|
88
|
+
'9 min for a bug is already agent-scale — translating it says nothing useful');
|
|
89
|
+
assert.equal(adviseEstimate({ estMinutes: 2, kind: 'bug', summary: SUMMARY }), null);
|
|
90
|
+
assert.equal(adviseEstimate({ estMinutes: 4, kind: 'feature', summary: SUMMARY }), null);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
await t('the loop closes at the POPULATION level, not per task', () => {
|
|
94
|
+
// The tempting claim — "adopt the suggestion and it goes quiet" — is FALSE, and
|
|
95
|
+
// asserting it is how a wrong comment would have shipped. The calibration is a
|
|
96
|
+
// constant multiply, so the adopted value gets translated too:
|
|
97
|
+
const loud = adviseEstimate({ estMinutes: 300, kind: 'bug', summary: SUMMARY });
|
|
98
|
+
assert.ok(loud, 'a human-hours estimate is flagged');
|
|
99
|
+
assert.ok(adviseEstimate({ estMinutes: loud.calibrated_minutes, kind: 'bug', summary: SUMMARY }),
|
|
100
|
+
'adopting the value does NOT silence it — the multiplier re-applies');
|
|
101
|
+
|
|
102
|
+
// What DOES retire the advisory: the population correcting, which raises the
|
|
103
|
+
// median ratio toward 1.0 (pms_calibration_summary recomputes from shipped
|
|
104
|
+
// rows). Then even a large estimate translates to nearly itself.
|
|
105
|
+
const corrected = [{ kind: 'bug', median_ratio: '0.9500', sample_size: 400 }];
|
|
106
|
+
assert.equal(adviseEstimate({ estMinutes: 300, kind: 'bug', summary: corrected }), null,
|
|
107
|
+
'once estimates and actuals agree, the advisory has nothing to say — at ANY size');
|
|
108
|
+
assert.equal(adviseEstimate({ estMinutes: 5000, kind: 'bug', summary: corrected }), null,
|
|
109
|
+
'and size alone must never trigger it — this is a SCALE advisory, not a big-number one');
|
|
110
|
+
assert.ok(MIN_MATERIAL_DELTA > 0 && SCALE_OK_RATIO > 0 && SCALE_OK_RATIO < 1);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// ---- 3/4. which ratio, and why ----
|
|
114
|
+
|
|
115
|
+
await t('pickRatio uses the KIND when it has enough samples, and says so', () => {
|
|
116
|
+
const p = pickRatio({ kind: 'feature', summary: SUMMARY });
|
|
117
|
+
assert.equal(p.basis, 'feature');
|
|
118
|
+
assert.equal(p.sample_size, 489);
|
|
119
|
+
assert.equal(p.ratio, 0.078);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
await t('pickRatio falls back off a THIN kind rather than quoting 2 samples', () => {
|
|
123
|
+
// blocker-resolution has n=2, below the >=5 floor pms_calibrated_minutes uses.
|
|
124
|
+
const p = pickRatio({ kind: 'blocker-resolution', summary: SUMMARY });
|
|
125
|
+
assert.notEqual(p.basis, 'blocker-resolution', 'must not quote a 2-sample median as authority');
|
|
126
|
+
assert.equal(p.basis, 'all kinds');
|
|
127
|
+
assert.ok(p.sample_size > 900, 'the overall basis carries the whole population');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
await t('an unknown kind still gets the overall basis', () => {
|
|
131
|
+
const p = pickRatio({ kind: 'nonexistent-kind', summary: SUMMARY });
|
|
132
|
+
assert.equal(p.basis, 'all kinds');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// ---- 5. fail closed ----
|
|
136
|
+
|
|
137
|
+
await t('no estimate, no history, or hostile input ⇒ null, never a guess', () => {
|
|
138
|
+
assert.equal(adviseEstimate({ estMinutes: null, kind: 'bug', summary: SUMMARY }), null);
|
|
139
|
+
assert.equal(adviseEstimate({ estMinutes: 0, kind: 'bug', summary: SUMMARY }), null);
|
|
140
|
+
assert.equal(adviseEstimate({ estMinutes: -5, kind: 'bug', summary: SUMMARY }), null);
|
|
141
|
+
assert.equal(adviseEstimate({ estMinutes: 'sixty', kind: 'bug', summary: SUMMARY }), null);
|
|
142
|
+
// Thin overall population: nothing to claim.
|
|
143
|
+
assert.equal(adviseEstimate({ estMinutes: 600, kind: 'bug', summary: [{ kind: 'bug', median_ratio: '0.15', sample_size: 3 }] }), null);
|
|
144
|
+
assert.equal(adviseEstimate({ estMinutes: 600, kind: 'bug', summary: [] }), null);
|
|
145
|
+
assert.equal(adviseEstimate({ estMinutes: 600, kind: 'bug', summary: null }), null);
|
|
146
|
+
// Garbage rows must not crash or be trusted.
|
|
147
|
+
assert.equal(adviseEstimate({ estMinutes: 600, kind: 'bug', summary: [{ kind: 'bug', median_ratio: 'x', sample_size: 'y' }] }), null);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
await t('buildEstAdvisory is NON-BLOCKING — a db failure yields null, not a throw', async () => {
|
|
151
|
+
const thrower = { calibrationSummary: async () => { throw new Error('pg down'); } };
|
|
152
|
+
assert.equal(await buildEstAdvisory({ db: thrower, task: { id: 1, kind: 'bug', est_minutes: 600 } }), null);
|
|
153
|
+
// No estimate on the task at all: nothing to judge, and no db call needed.
|
|
154
|
+
let called = false;
|
|
155
|
+
const spy = { calibrationSummary: async () => { called = true; return SUMMARY; } };
|
|
156
|
+
assert.equal(await buildEstAdvisory({ db: spy, task: { id: 1, kind: 'bug', est_minutes: null } }), null);
|
|
157
|
+
assert.equal(called, false, 'must not query when there is no estimate to judge');
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
await t('buildEstAdvisory returns the advisory on the happy path', async () => {
|
|
161
|
+
const db = { calibrationSummary: async () => SUMMARY };
|
|
162
|
+
const a = await buildEstAdvisory({ db, task: { id: 77, kind: 'feature', est_minutes: 90 } });
|
|
163
|
+
assert.ok(a);
|
|
164
|
+
assert.equal(a.calibrated_minutes, 7, '90 * 0.078 = 7');
|
|
165
|
+
assert.equal(a.basis, 'feature');
|
|
166
|
+
assert.match(a.next_step, /PATCH \/tasks\/77/);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
await t('the route attaches est_advisory to the 201 body, non-blocking', () => {
|
|
170
|
+
// Mirrors the goal_advisory contract test. The call is folded into the response
|
|
171
|
+
// literal because modules/lifecycle/routes/tasks.js is over the size ratchet's
|
|
172
|
+
// cap and main sits exactly AT the baseline — so this feature had to land at
|
|
173
|
+
// net-zero lines in that file. Asserting the shape keeps the fold honest.
|
|
174
|
+
const src = fs.readFileSync(new URL('../modules/lifecycle/routes/tasks.js', import.meta.url), 'utf8');
|
|
175
|
+
const body201 = (src.match(/res\.status\(201\)\.json\(\{[\s\S]*?\}\);/) || [''])[0];
|
|
176
|
+
assert.ok(body201, 'found the 201 response body');
|
|
177
|
+
assert.match(body201, /est_advisory:/, 'est_advisory rides the create response');
|
|
178
|
+
assert.match(body201, /buildEstAdvisory\(\{ db, task \}\)/, 'and delegates rather than inlining logic');
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
console.log('');
|
|
182
|
+
console.log(`est_advisory: ${passed} passed, ${failed} failed`);
|
|
183
|
+
process.exit(failed === 0 ? 0 : 1);
|
package/tests/goal_advisory.mjs
CHANGED
|
@@ -117,7 +117,12 @@ await test('db.js resolves the catch-all through generalGoalTitle — no second
|
|
|
117
117
|
|
|
118
118
|
await test('the route delegates to buildGoalAdvisory in one line — no inline logic to drift', () => {
|
|
119
119
|
assert.match(tasksSrc, /const goal_advisory = await buildGoalAdvisory\(\{ db, versionId: body\.version_id, task \}\);/);
|
|
120
|
-
|
|
120
|
+
// task 1003148: was /goal_advisory,\s*\}\);/ — it required goal_advisory to be
|
|
121
|
+
// the LAST key, which the sibling est_advisory broke. The assertion's intent is
|
|
122
|
+
// "present in the 201 body", so assert that, not the key order.
|
|
123
|
+
const body201 = (tasksSrc.match(/res\.status\(201\)\.json\(\{[\s\S]*?\}\);/) || [''])[0];
|
|
124
|
+
assert.ok(body201, 'found the 201 response body');
|
|
125
|
+
assert.match(body201, /goal_advisory,/, 'included in the 201 body');
|
|
121
126
|
});
|
|
122
127
|
|
|
123
128
|
// ---- buildGoalAdvisory: the real function, driven with a stub db ----
|