@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 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.273",
6
- "core_contract": "1.19.273",
7
- "source_commit": "5537eeef23f8f0daa60fcb4a167c9a95ec085a17",
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-21T02:47:17.902Z",
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": 1370,
14
+ "functional_verbatim": 1372,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 1710,
20
- "tree_sha256": "6446c48d86f464c04e57b8332758031a3427ca7756c6dc01ec7933c989635dde",
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": "95193b64dcf1121f10fbc871a07cb7997a27a64d3f7b2176957abdae28979729"
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": "18002184d4180e23e816413d0ea52da34989862b7ad7021b1df84790efed6602"
4480
+ "sha256": "2aa1f6741a57aabd4570045588e71afbc04bd100b19fefed328dad581d9bb4f6"
4476
4481
  },
4477
4482
  {
4478
4483
  "path": "package.json",
4479
4484
  "mode": "0000644",
4480
- "sha256": "db5a0de515b3ba49f58b4fa629136f0ff9327bfaa45c5ac8fd994751f2390c07"
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": "c8c489bd626bd990b5d2230f8f535350abb2cf754910fd3bc7869e56fb1444ce"
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": "c4eb5f6a36faed5c90b500c20bc84944dba62a0af06d4c88c08eaa684bf96aad"
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.273",
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.273",
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.273",
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.273'; // CI auto-patch carrier (ADR 0161); see changelog above
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);
@@ -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
- assert.match(tasksSrc, /goal_advisory,\s*\}\);/, 'included in the 201 body');
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 ----