@bongos/core 1.19.275 → 1.19.277

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.
@@ -19,7 +19,7 @@ const api = require('../../src/module-api');
19
19
  const { pool } = api;
20
20
  // The landing matrix (task 1003065, BV1.R01) — pure, DB-free, its own file so
21
21
  // every cell is unit-testable without a Postgres. See ./routing.js.
22
- const { decideLanding, routedPriority } = require('./routing');
22
+ const { decideLanding, authorizeRoutedLanding, routedPriority } = require('./routing');
23
23
 
24
24
  const VALID_KINDS = [
25
25
  'feature',
@@ -227,6 +227,12 @@ async function captureIdea({
227
227
  goalId = null,
228
228
  filerRank = null,
229
229
  grade = null,
230
+ // task 1003066 (BV1.R05): OPTIONAL declared blast radius. A human reporting a
231
+ // bug never supplies this — demanding it as the price of reporting is the
232
+ // triage tax this goal removes — but a programmatic filer that DOES know
233
+ // (a hall filing surface, a Discord classifier) can, and when it does the
234
+ // goal's scope wall is applied to it verbatim. See ./routing.js.
235
+ touches = null,
230
236
  }, deps = {}) {
231
237
  if (!title || typeof title !== 'string') {
232
238
  throw { code: 'TITLE_REQUIRED' };
@@ -248,7 +254,7 @@ async function captureIdea({
248
254
  if (landing.route) {
249
255
  return routeIdeaToGoal({
250
256
  title, bodyMd, resolvedKind, suggestedVersion, suggestedPriority,
251
- capturedBy, goalId, landing,
257
+ capturedBy, goalId, landing, filerRank, touches,
252
258
  }, activePool);
253
259
  }
254
260
 
@@ -289,7 +295,7 @@ async function captureIdea({
289
295
  // the same two-step ratifyCriterionProposal takes.
290
296
  async function routeIdeaToGoal({
291
297
  title, bodyMd, resolvedKind, suggestedVersion, suggestedPriority,
292
- capturedBy, goalId, landing,
298
+ capturedBy, goalId, landing, filerRank = null, touches = null,
293
299
  }, activePool) {
294
300
  const client = await activePool.connect();
295
301
  try {
@@ -305,7 +311,7 @@ async function routeIdeaToGoal({
305
311
  // catch-all where nobody would ever look again. A closed version cannot
306
312
  // receive new work — the route refuses rather than filing into a tomb.
307
313
  const { rows: goalRows } = await client.query(
308
- `SELECT g.id, g.status, g.version_id, v.status AS version_status
314
+ `SELECT g.id, g.status, g.version_id, g.scope_modules, v.status AS version_status
309
315
  FROM goals g
310
316
  JOIN versions v ON v.id = g.version_id
311
317
  WHERE g.id = $1
@@ -341,6 +347,36 @@ async function routeIdeaToGoal({
341
347
  );
342
348
  const idea = ideaRows[0];
343
349
 
350
+ // b2. THE BINDING GATE (task 1003066, BV1.R05). The matrix said what status;
351
+ // this says whether the filing is allowed at all and what rank floor the
352
+ // task is born with. Both facts come off the goal row we just LOCKED, so
353
+ // a rescope cannot land between the check and the insert.
354
+ //
355
+ // scopeIncludesProtected + goalScopeCheck both come through the published
356
+ // doorway — the SAME protected roster the pre-push hook, the grader and
357
+ // main-audit read (permission-path-check), so this wall cannot drift from
358
+ // them. The wall only runs when touches were declared; see ./routing.js
359
+ // for why an absent touches[] moves the protection to the rank floor
360
+ // instead of refusing the report.
361
+ const declaredTouches = Array.isArray(touches) ? touches.filter(Boolean) : [];
362
+ const authorized = authorizeRoutedLanding({
363
+ landing,
364
+ filerRank,
365
+ scopeProtected: api.moduleScopeMap.scopeIncludesProtected(goal.scope_modules),
366
+ scopeCheck: declaredTouches.length
367
+ ? api.goalScopeCheck.check(goal.scope_modules, declaredTouches, filerRank)
368
+ : null,
369
+ });
370
+ if (!authorized.ok) {
371
+ throw {
372
+ code: 'ROUTE_NOT_AUTHORIZED',
373
+ goal_id: goalId,
374
+ gate: authorized.code,
375
+ reason: authorized.reason,
376
+ details: authorized.details ?? null,
377
+ };
378
+ }
379
+
344
380
  // c. The task, through the `lifecycle` PORT — never an import of
345
381
  // modules/lifecycle/ (the one-way module rule, ADR 0083). Resolved at call
346
382
  // time; the port registers at boot. Passing `client` enlists the INSERT in
@@ -354,12 +390,13 @@ async function routeIdeaToGoal({
354
390
  // goal fallback, which only runs when goalId is absent and so never on
355
391
  // this path.
356
392
  //
357
- // What is deliberately NOT passed: `requiresRank` (createTask derives the
358
- // ADR 0084/0085 floor itself from touches + sensitivity, and an explicit
359
- // value could only raise it — a filer must not be able to lower a floor),
360
- // `creditsReward` (server default — createTask auto-assigns the capped
361
- // suggestion), and `discipline` (left 'unclassified', exactly as the other
362
- // non-authored create vectors leave it).
393
+ // `requiresRank` is passed ONLY as the R05 protected-scope floor computed
394
+ // above — never from anything the filer said. createTask still derives its
395
+ // own floor and takes the HIGHER of the two, so this can raise the bar and
396
+ // never lower it. Deliberately NOT passed at all: `creditsReward` (server
397
+ // default — createTask auto-assigns the capped suggestion) and
398
+ // `discipline` (left 'unclassified', exactly as the other non-authored
399
+ // create vectors leave it).
363
400
  const lifecycle = api.resolveOptional && api.resolveOptional('lifecycle');
364
401
  if (!lifecycle || !lifecycle.createTask) {
365
402
  // A vanilla instance without the lifecycle module cannot hold tasks at all,
@@ -371,10 +408,16 @@ async function routeIdeaToGoal({
371
408
  versionId: goal.version_id,
372
409
  title,
373
410
  description: bodyMd,
374
- status: landing.status,
411
+ status: authorized.status,
375
412
  kind: resolvedKind,
376
413
  priority: routedPriority(suggestedPriority),
377
414
  goalId: goal.id,
415
+ touches: declaredTouches,
416
+ // task 1003066 (BV1.R05): the protected-scope floor. null on an ordinary
417
+ // goal, so createTask derives the floor exactly as for every other vector;
418
+ // 'metic' when the goal reaches protected territory, and highestRank()
419
+ // clamps UPWARD only — this can raise a floor, never drop one.
420
+ ...(authorized.requiresRank ? { requiresRank: authorized.requiresRank } : {}),
378
421
  // The provenance pair the create-vector idempotency read keys on
379
422
  // (db.findTaskBySourceRef): one idea can only ever mint one routed task.
380
423
  source: 'idea-route',
@@ -406,7 +449,8 @@ async function routeIdeaToGoal({
406
449
  routing: {
407
450
  routed: true,
408
451
  reason: landing.reason,
409
- status: landing.status,
452
+ status: authorized.status,
453
+ requires_rank: authorized.requiresRank,
410
454
  goal_id: goal.id,
411
455
  version_id: goal.version_id,
412
456
  task_id: task.id,
@@ -636,6 +680,66 @@ async function sourceIdeaForTask(taskId) {
636
680
  return rows[0] || null;
637
681
  }
638
682
 
683
+ // listRoutedAwaitingNod — the NOD QUEUE (task 1003066, BV1.R05).
684
+ //
685
+ // A routed filing that soft-landed is a task sitting at 'backlog' in a real goal,
686
+ // one Metic+ promote from claimable. Owner interview Q3 settled where that queue
687
+ // lives: the EXISTING "Needs your attention" card and the EXISTING
688
+ // POST /tasks/:id/promote — no new surface, no new permission. This is the read
689
+ // that card was missing, and without it the soft landing is a silent drop: the
690
+ // filing looks accepted, nothing tells anyone it needs a nod, and it sits at
691
+ // backlog forever. Criterion C2's "one Metic+ promote away" is only true if
692
+ // somebody can SEE it.
693
+ //
694
+ // Scoped by the lineage row (origin='route'), not by status alone — a task can be
695
+ // at backlog for a dozen unrelated reasons, and this queue is specifically "a
696
+ // person reported this and the system is waiting on a human". It reads the FILER
697
+ // too (captured_by), because Q3's copy calls the pending state "awaiting a nod"
698
+ // and shows kind + who filed it.
699
+ //
700
+ // Deliberately NOT filtered by goal membership or by the viewer: any Metic+ may
701
+ // give the nod (Q3 rejected goal-member-first review as surface cost), so every
702
+ // Metic+ sees one queue. The route is what gates rank; this is a plain read.
703
+ async function listRoutedAwaitingNod({ limit = 20 } = {}, deps = {}) {
704
+ const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
705
+ const { rows } = await activePool.query(
706
+ `SELECT t.id AS task_id, t.title, t.kind, t.status, t.goal_id, t.version_id,
707
+ t.requires_rank, t.created_at,
708
+ l.idea_id, i.captured_by, b.github_login AS filed_by_login,
709
+ g.title AS goal_title
710
+ FROM ideas_task_lineage l
711
+ JOIN tasks t ON t.id = l.task_id
712
+ JOIN idea_inbox i ON i.id = l.idea_id
713
+ LEFT JOIN builders b ON b.id = i.captured_by
714
+ LEFT JOIN goals g ON g.id = t.goal_id
715
+ WHERE l.origin = 'route'
716
+ AND t.status = 'backlog'
717
+ ORDER BY t.created_at ASC
718
+ LIMIT $1`,
719
+ // The origin filter rides idx_ideas_task_lineage_route (ideas_004): a PARTIAL
720
+ // index on origin='route' keyed by task_id, so this walks the routed rows and
721
+ // probes tasks from the same structure. Without it this was a full scan of a
722
+ // monotonically-growing ledger on EVERY Home load for every Metic+ builder.
723
+ [limit]
724
+ );
725
+ return rows;
726
+ }
727
+
728
+ // The count behind the card's pill — same predicate as the list, so the number a
729
+ // viewer is shown can never disagree with the rows they can see (the
730
+ // countOpenIdeas precedent two functions up).
731
+ async function countRoutedAwaitingNod(deps = {}) {
732
+ const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
733
+ const { rows } = await activePool.query(
734
+ `SELECT COUNT(*)::int AS n
735
+ FROM ideas_task_lineage l
736
+ JOIN tasks t ON t.id = l.task_id
737
+ WHERE l.origin = 'route' AND t.status = 'backlog'`
738
+ // Same predicate ⇒ same index (ideas_004) as the list above.
739
+ );
740
+ return rows[0] ? rows[0].n : 0;
741
+ }
742
+
639
743
  // -------------------------------------------------------------------------
640
744
  // Grade writes (task 1002916 / BV1.R140)
641
745
  //
@@ -959,6 +1063,9 @@ module.exports = {
959
1063
  linkIdeaTask,
960
1064
  countShippedTasksForIdea,
961
1065
  sourceIdeaForTask,
1066
+ // the nod queue (task 1003066, BV1.R05)
1067
+ listRoutedAwaitingNod,
1068
+ countRoutedAwaitingNod,
962
1069
  // criterion-proposal ratification (V3.R31 / #252)
963
1070
  ratifyCriterionProposal,
964
1071
  parseProposalBody,
@@ -0,0 +1,37 @@
1
+ -- ideas_004_lineage_route_index.sql — index the ROUTE lineage lookup
2
+ -- (task 1003066, BV1.R05, goal 1000071 "Idea routing").
3
+ --
4
+ -- WHY. R05 added the nod queue: GET /inbox/awaiting-nod lists routed filings that
5
+ -- soft-landed, filtering `ideas_task_lineage.origin = 'route'` and joining tasks
6
+ -- for status='backlog'. ideas_002 indexes only task_id (the PK) and idea_id —
7
+ -- nothing on `origin` — so that filter was a full scan of a table that grows
8
+ -- monotonically with every promoted or routed idea, forever. And it is not a rare
9
+ -- query: the hall's "Needs your attention" card issues it on EVERY Home page load
10
+ -- for EVERY Metic+ builder, so the scan cost arrives per page view, not per triage
11
+ -- session. (Raised by the grading panel's efficiency worker on the R05 diff.)
12
+ --
13
+ -- WHY PARTIAL, AND WHY KEYED ON task_id. 'route' is one of three origins and will
14
+ -- stay the minority for as long as triage-promotion exists, so `WHERE origin =
15
+ -- 'route'` keeps the index proportional to the routed rows rather than the whole
16
+ -- table. Keying it on task_id means the index serves the JOIN to tasks directly
17
+ -- from the same structure that satisfies the filter — the planner can walk the
18
+ -- routed rows and probe tasks without touching the heap for a discarded origin.
19
+ --
20
+ -- WHY NOT CONCURRENTLY. Same reasoning core_222 recorded for its own index:
21
+ -- CREATE INDEX CONCURRENTLY cannot run inside a transaction block, and this file
22
+ -- is wrapped BEGIN/COMMIT so it lands atomically. The lock is acceptable HERE
23
+ -- specifically because ideas_task_lineage is a low-volume human-filing ledger —
24
+ -- one row per promoted/routed idea, arriving at human pace — so the build is
25
+ -- milliseconds against a small table and no write path of consequence blocks.
26
+ -- This does NOT generalise to a write-hot table.
27
+ --
28
+ -- Module migrations are ADDITIVE and NAMESPACED: every object here is
29
+ -- ideas_-prefixed, no down-migration, idempotent (safe to re-run).
30
+
31
+ BEGIN;
32
+
33
+ CREATE INDEX IF NOT EXISTS idx_ideas_task_lineage_route
34
+ ON ideas_task_lineage (task_id)
35
+ WHERE origin = 'route';
36
+
37
+ COMMIT;
@@ -229,6 +229,15 @@ module.exports = function buildInboxRouter() {
229
229
  err.code === 'GOAL_NOT_FOUND' ? 404 :
230
230
  err.code === 'GOAL_NOT_OPEN' ? 409 :
231
231
  err.code === 'VERSION_NOT_BUILDING' ? 409 :
232
+ // task 1003066 (BV1.R05): the landing gate refused. Either the goal's
233
+ // scope reaches protected territory and the filer is below Metic
234
+ // (gate 'protected_scope'), or they DECLARED touches the wall rejected
235
+ // (gate 'out_of_scope' / 'protected_path'). 403 and not 400 — the
236
+ // request is well-formed, the FILER lacks the authority — matching what
237
+ // POST /goals/:id/tasks returns for the same wall, so one refusal has
238
+ // one status. `gate` names which rule bit, and the reason tells the
239
+ // filer the way through: file it without a goal.
240
+ err.code === 'ROUTE_NOT_AUTHORIZED' ? 403 :
232
241
  err.code === 'LIFECYCLE_UNAVAILABLE' ? 503 :
233
242
  500;
234
243
  return res.fail(err.code, status, { ...err });
@@ -268,6 +277,41 @@ module.exports = function buildInboxRouter() {
268
277
  }
269
278
  });
270
279
 
280
+ // GET /inbox/awaiting-nod — the NOD QUEUE (task 1003066, BV1.R05).
281
+ //
282
+ // REGISTERED BEFORE /inbox/:id ON PURPOSE. Express matches in registration
283
+ // order, so a literal segment declared after the wildcard is unreachable —
284
+ // this would arrive as /inbox/:id with id='awaiting-nod' and 400 on parseId.
285
+ //
286
+ // rank: metic+ via the EXISTING idea.triage permission, deliberately reused
287
+ // rather than minting a new one. Owner interview Q3: "any Metic+ gives the nod,
288
+ // in the existing Needs-your-attention slice. No new surface, no new
289
+ // permission." The act this queue leads to is POST /tasks/:id/promote, itself
290
+ // gated task.promote (Metic+), so a reader who can see this queue can act on it
291
+ // — the two gates agree by construction instead of by coincidence.
292
+ //
293
+ // Placed on /inbox rather than /tasks because the row is defined by its LINEAGE
294
+ // (origin='route'), and ideas_task_lineage is an ideas-owned table — the
295
+ // lifecycle module must not query it, and this module must not query it from
296
+ // there. "A task at backlog that a person reported" is an ideas question.
297
+ router.get('/inbox/awaiting-nod', api.requireBuilder, api.requirePermission('idea.triage'), async (req, res) => {
298
+ try {
299
+ const limitRaw = Number(req.query.limit);
300
+ const limit = Number.isInteger(limitRaw) && limitRaw > 0 && limitRaw <= 100 ? limitRaw : 20;
301
+ const [awaiting, count] = await Promise.all([
302
+ inbox.listRoutedAwaitingNod({ limit }),
303
+ inbox.countRoutedAwaitingNod(),
304
+ ]);
305
+ res.json({ awaiting, count });
306
+ } catch (err) {
307
+ // Through the published logger seam, not a raw console call: a module logs
308
+ // via the doorway, and the console_call_count ratchet holds at its floor
309
+ // (the task 1003148 precedent).
310
+ api.logger('ideas').error('GET /inbox/awaiting-nod', err && err.message);
311
+ res.fail('awaiting_nod_failed', { status: 500, message: 'internal error' });
312
+ }
313
+ });
314
+
271
315
  // rank: any-builder — single idea read for the "#NNN" deep-link resolver
272
316
  // (#1047). Mirrors GET /blockers/:id. The hall resolves /#/idea/N by id, but
273
317
  // the /inbox LIST returns only the open-first-50, so a closed/older idea ref
@@ -111,8 +111,119 @@ function routedPriority(suggestedPriority) {
111
111
  return p;
112
112
  }
113
113
 
114
+ // -------------------------------------------------------------------------
115
+ // ENFORCEMENT (task 1003066, BV1.R05) — the matrix decides, this gate BINDS.
116
+ //
117
+ // R01 landed decideLanding: what status a filing lands at. That is a decision
118
+ // about the QUEUE. It is not authority, and on its own it left a real hole:
119
+ // a routed task declares no touches[], so createTask's ADR 0084/0085 rank floor
120
+ // derives from an empty list and comes out 'xenos' — meaning a bug routed into a
121
+ // goal scoped to PROTECTED modules (permission/ship-grade/deploy/migration core)
122
+ // was born claimable by a Xenos the moment any Metic+ nodded it. The matrix's
123
+ // rank split does not close that: it governs who can make work READY, not who
124
+ // can then take it.
125
+ //
126
+ // WHY THIS ISN'T goalScopeCheck.check() CALLED VERBATIM, which is what
127
+ // POST /goals/:id/tasks does. That function DENIES on `empty_touches` — by
128
+ // design, because an AUTHORED task whose blast radius nobody declared cannot be
129
+ // bounded. Applied to a route it would refuse every single filing, since a person
130
+ // reporting a bug does not know which files it lives in; demanding touches[] as
131
+ // the price of reporting is exactly the triage tax this goal removes. So the wall
132
+ // is applied where it has something to bite on:
133
+ //
134
+ // * touches DECLARED (a programmatic filer that does know) → the real wall,
135
+ // verbatim, refusals and all.
136
+ // * touches ABSENT (every human filing) → nothing to subset-check, so the
137
+ // protection moves to the CLAIM side: the goal's protected scope raises the
138
+ // task's rank floor, and no sub-Metic can claim it however it got promoted.
139
+ //
140
+ // That is the same protected-roster source (permission-path-check via
141
+ // goalScopeCheck / moduleScopeMap), just enforced at the gate that still exists
142
+ // when touches[] doesn't.
143
+ //
144
+ // MEMBERSHIP IS DELIBERATELY *NOT* REQUIRED, and this is the one place the routed
145
+ // path diverges from the authored one on purpose. authorizeGoalTaskCreate refuses
146
+ // a non-member with not_a_member, because authoring work inside someone's
147
+ // workspace is a member's privilege. REPORTING is not authoring: criterion C5's
148
+ // whole promise is that "the filer never has to know the goal map", and C1's is
149
+ // that a bug filed anywhere lands in its home. Requiring membership would mean
150
+ // only insiders could report against a goal — the opposite of the goal. The
151
+ // safety that membership was carrying is carried here instead by the rank floor
152
+ // (claim side) and the matrix (ready side). Pinned by a test, so a later change
153
+ // has to argue with the test rather than quietly reinstate the wall.
154
+
155
+ // The rank a routed task must require when its goal's scope reaches protected
156
+ // territory. Metic is the trust boundary docs/canonical-permissions.md draws for
157
+ // the permission/ship/deploy core; createTask's highestRank() clamps UPWARD only,
158
+ // so handing this in can raise a floor and never lower one.
159
+ const PROTECTED_SCOPE_FLOOR = 'metic';
160
+
161
+ // authorizeRoutedLanding — the FINAL, binding landing decision. PURE: it takes
162
+ // facts the caller already resolved (the matrix verdict, whether the goal's scope
163
+ // includes a protected module, and the scope wall's own verdict if touches were
164
+ // declared) and returns what to stamp on the task, or a refusal.
165
+ //
166
+ // Ordered + fail-closed, the authorizeGoalTaskCreate precedent:
167
+ // not routing → scope-wall verdict → the floor.
168
+ // Returns { ok: true, status, requiresRank } or { ok: false, code, reason, details }.
169
+ function authorizeRoutedLanding(input) {
170
+ const { landing = null, scopeProtected = false, scopeCheck = null, filerRank = null } = input || {};
171
+
172
+ // Defensive: only a routing verdict may reach here. A caller that lost track of
173
+ // its own matrix result must not fall through into "authorized".
174
+ if (!landing || landing.route !== true) {
175
+ return { ok: false, code: 'not_routing', reason: 'authorizeRoutedLanding was asked to authorize a filing the matrix did not route.', details: null };
176
+ }
177
+
178
+ // The wall, when touches gave it something to check. Its verdict is passed
179
+ // through UNCHANGED — code, reason and details — so a refusal reads identically
180
+ // whether it came from a routed filing or an authored task, and there is only
181
+ // ever one definition of "out of scope" / "protected path".
182
+ if (scopeCheck && scopeCheck.ok === false) {
183
+ return { ok: false, code: scopeCheck.code, reason: scopeCheck.reason, details: scopeCheck.details ?? null };
184
+ }
185
+
186
+ // PROTECTED SCOPE + SUB-METIC FILER ⇒ REFUSE, not merely floor-stamp.
187
+ //
188
+ // This closes the one place the first cut of R05 was genuinely weaker than the
189
+ // authored gate rather than differently-shaped. With no touches[] there is no
190
+ // path for the protected-path arm to inspect, so the earlier version let a
191
+ // sub-Metic CREATE a task in protected territory and relied on the rank floor to
192
+ // stop them CLAIMING it. But POST /goals/:id/tasks refuses that author outright
193
+ // (protected_path), and admission refuses them the goal entirely
194
+ // (authorizeInvitationAccept: a sub-Metic may not even join a protected-scope
195
+ // goal). Letting the routed path mint a row there was the odd one out.
196
+ //
197
+ // Refusing costs the filer nothing they had before: omit goal_id and the report
198
+ // files to the inbox exactly as it always did, where a Metic+ triages it. What
199
+ // it removes is a sub-Metic's ability to put rows — however unclaimable — into
200
+ // the permission/ship/deploy core's queue, which is precisely what "the scope
201
+ // wall runs on routed tasks as on authored ones" is asking for.
202
+ //
203
+ // The rank floor below still applies to Metic+ filings in protected scope; the
204
+ // two are layers, not alternatives.
205
+ if (scopeProtected && !METIC_PLUS.has(String(filerRank || '').toLowerCase())) {
206
+ return {
207
+ ok: false,
208
+ code: 'protected_scope',
209
+ reason: `This goal's scope reaches protected territory (the permission / ship-grade / deploy / migration core), so a filer below Metic cannot route work into it. File it without a goal — it lands in the inbox and a Metic+ routes it from there.`,
210
+ details: { required_rank: PROTECTED_SCOPE_FLOOR, filer_rank: filerRank ?? null },
211
+ };
212
+ }
213
+
214
+ return {
215
+ ok: true,
216
+ status: landing.status,
217
+ // null ⇒ say nothing and let createTask derive the floor exactly as it does
218
+ // for every other create vector. Only the protected case speaks up.
219
+ requiresRank: scopeProtected ? PROTECTED_SCOPE_FLOOR : null,
220
+ };
221
+ }
222
+
114
223
  module.exports = {
115
224
  decideLanding,
225
+ authorizeRoutedLanding,
226
+ PROTECTED_SCOPE_FLOOR,
116
227
  routedPriority,
117
228
  DEFAULT_ROUTED_PRIORITY,
118
229
  HARD_LAND_KINDS,
@@ -174,6 +174,59 @@ function nextActionFor(stage, { taskId, discipline, sandboxUrl } = {}) {
174
174
  return { kind: 'prompt', label: 'Continue', cmd: '/builder-start' };
175
175
  }
176
176
 
177
+ // ---- the session boundary (task 1003150) -----------------------------------
178
+ //
179
+ // A shipped task is the one moment the system KNOWS a session's work is finished,
180
+ // and it is the only moment when almost everything in that session's context
181
+ // becomes dead weight: superseded diffs, resolved test failures, closed grade
182
+ // rounds. Carrying it into the next task costs real time, because every turn
183
+ // re-reads the whole conversation.
184
+ //
185
+ // MEASURED, session of 2026-08-21 — three tasks shipped in one window:
186
+ // task 1003148 262K new + 11.3M cached
187
+ // task 1003065 582K new + 36.1M cached
188
+ // task 1003066 891K new + 78.1M cached
189
+ // The third was not twice the work of the second; it re-processed twice the
190
+ // context. The builder's own report was that everything felt slow, and this was
191
+ // the largest single contributor.
192
+ //
193
+ // WHY THIS LIVES ON THE CARD AND NOT IN A HOOK. A hook cannot clear a
194
+ // conversation, and /session-handoff is read-only by its own spec ("composes text
195
+ // and changes no state") — it has to be invoked. The harness does auto-compact on
196
+ // context PRESSURE, but that fires on size rather than meaning: as likely to
197
+ // compact mid-task, where the detail is load-bearing, as after a ship, where it is
198
+ // not. The ship card is the only component that is already running, already at the
199
+ // boundary, and already printing to the builder — so the nudge costs one line and
200
+ // no new machinery.
201
+ //
202
+ // THE THRESHOLD is deliberately generous. The advice is only useful if it is rare:
203
+ // a line that prints on every ship is wallpaper (the deploy-divergence footer is
204
+ // the cautionary tale — 21 core versions of identical warning that nobody acted
205
+ // on). 25M cached sits between the 11.3M session that felt fine and the 36.1M one
206
+ // that had begun to drag, so a short session never sees it.
207
+ const HEAVY_SESSION_CACHED_TOKENS = 25_000_000;
208
+
209
+ // handoffAdvice — PURE. null when there is nothing to say (the overwhelming
210
+ // majority of ships), else the one-line nudge. Exported so every branch is pinned
211
+ // by a DB-free unit test.
212
+ //
213
+ // Gated on a TERMINAL stage, not merely a big number: mid-task the context is what
214
+ // the work is standing on, and telling someone to reset in the middle of a claim is
215
+ // actively wrong. 'shipped' and 'confirmed' are the two stages where the task is
216
+ // out of the builder's hands.
217
+ function handoffAdvice(input) {
218
+ // Normalise BEFORE destructuring. A default parameter only fires on `undefined`,
219
+ // so an explicit `null` — what a caller spreading an absent object hands over —
220
+ // would throw. This runs at the very end of a successful ship, so a crash here
221
+ // would turn a landed task into a traceback. (Same trap as decideLanding in
222
+ // modules/ideas/routing.js, task 1003065 — it caught me twice in one session.)
223
+ const { stage, cachedTokens } = input || {};
224
+ if (stage !== 'shipped' && stage !== 'confirmed') return null;
225
+ const cached = Number(cachedTokens);
226
+ if (!Number.isFinite(cached) || cached < HEAVY_SESSION_CACHED_TOKENS) return null;
227
+ return `heavy session (${fmtTokens(cached)} cached) — run /session-handoff, then start the next task in a fresh session`;
228
+ }
229
+
177
230
  function safeBrand() {
178
231
  try {
179
232
  const b = branding();
@@ -219,6 +272,7 @@ function buildCardModel(input = {}) {
219
272
 
220
273
  const rows = [];
221
274
  const facts = [];
275
+ let handoff = null; // task 1003150 — the session-boundary nudge; null on all but a heavy ship
222
276
  const addRow = (k, v) => { if (v != null && v !== '') rows.push({ k, v }); };
223
277
  const addFact = (label, value, opts = {}) => { if (value != null && value !== '') facts.push({ label, value, ...opts }); };
224
278
 
@@ -319,6 +373,10 @@ function buildCardModel(input = {}) {
319
373
 
320
374
  addRow('Value', value); addRow('Changed', changed); addRow('Grade', gradeText);
321
375
  addRow('Credits', creditsText); addRow('Usage', usageText); addRow('Deploy', deployText);
376
+ // task 1003150: the session-boundary nudge. Not a row — it qualifies the
377
+ // Next-> action rather than reporting a fact about the task, so both renderers
378
+ // hang it off the action. Usually null.
379
+ handoff = handoffAdvice({ stage, cachedTokens: usage.cachedTokens });
322
380
 
323
381
  addFact('', changed);
324
382
  if (gradeText) addFact('grade', gradeText, { cls: gradeCls });
@@ -345,7 +403,7 @@ function buildCardModel(input = {}) {
345
403
  notes: input.notes || '',
346
404
  description: input.description || '',
347
405
  issues,
348
- rows, facts, action,
406
+ rows, facts, action, handoff,
349
407
  };
350
408
  }
351
409
 
@@ -403,6 +461,9 @@ function renderMonospaceCard(model) {
403
461
  for (const r of m.rows) kv(r.k, r.v);
404
462
  out.push(bar);
405
463
  kv('Next →', m.action.kind === 'link' ? (m.action.url || '(sandbox)') : m.action.cmd);
464
+ // task 1003150: aligned under the Next action it qualifies, not as a row — this
465
+ // is guidance about the SESSION, not a fact about the task.
466
+ if (m.handoff) for (const l of wrap(`⚠ ${m.handoff}`, MONO_W - 9)) out.push(' ' + ' '.repeat(9) + l);
406
467
  return out.join('\n');
407
468
  }
408
469
 
@@ -459,6 +520,12 @@ function renderCardHtml(model) {
459
520
  actionHtml = `<button class="cc-link cc-next" onclick="sendPrompt('${cmd}')">${esc(m.action.label)} ↗</button>`;
460
521
  }
461
522
 
523
+ // task 1003150: the boundary nudge rides beside the action, muted — it is
524
+ // advice, not an alarm, and it must not outshout the Next button.
525
+ const handoffHtml = m.handoff
526
+ ? `<span class="cc-sep">·</span><span style="color:var(--color-text-tertiary);">${esc(m.handoff)}</span>`
527
+ : '';
528
+
462
529
  const factsSr = m.facts.map((f) => `${f.label ? f.label + ' ' : ''}${f.value}`).join('. ');
463
530
  const srText = `${m.cfg.headline(m.taskId)}. ${factsSr}. A collapsible summary expands on click.`;
464
531
 
@@ -490,7 +557,7 @@ function renderCardHtml(model) {
490
557
  ${actionHtml}
491
558
  </div>
492
559
  <div style="display:flex;align-items:baseline;gap:10px;">
493
- <div style="font-size:13px;color:var(--color-text-secondary);line-height:1.7;flex:1;min-width:0;">${facts}</div>
560
+ <div style="font-size:13px;color:var(--color-text-secondary);line-height:1.7;flex:1;min-width:0;">${facts}${handoffHtml}</div>
494
561
  <button class="cc-link cc-sum" aria-label="Toggle task summary" onclick="var s=this.parentElement.nextElementSibling;var o=getComputedStyle(s).display==='none';s.style.display=o?'block':'none';this.querySelector('i').style.transform=o?'rotate(90deg)':'';">Summary <span class="cc-chev"><i class="ti ti-chevron-right" aria-hidden="true"></i></span></button>
495
562
  </div>
496
563
  <div style="display:none;margin-top:0.6rem;padding-top:0.55rem;border-top:0.5px solid var(--color-border-tertiary);font-size:13px;line-height:1.6;">${summaryBody}</div>
@@ -538,5 +605,7 @@ module.exports = {
538
605
  STAGES, TONE, stageFor, buildCardModel, renderMonospaceCard, renderCardHtml, emitCard,
539
606
  // exported for unit tests:
540
607
  fmtTokens, fmtUsd, fmtInt, fmtMins, nextActionFor, esc, wrap, changedText,
608
+ // task 1003150 — the session-boundary nudge, pure so every branch is pinned.
609
+ handoffAdvice, HEAVY_SESSION_CACHED_TOKENS,
541
610
  PIPELINE, FORMS, trailSteps,
542
611
  };
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.275",
3
+ "version": "1.19.277",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.275",
9
+ "version": "1.19.277",
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.275",
3
+ "version": "1.19.277",
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
@@ -977,8 +977,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
977
977
  // landed since 1.19.273 with no explicit bump. run 32490024209. (task 1002620)
978
978
  // 1.19.275 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
979
979
  // landed since 1.19.274 with no explicit bump. run 32492414871. (task 1002620)
980
+ // 1.19.276 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
981
+ // landed since 1.19.275 with no explicit bump. run 32499257959. (task 1002620)
982
+ // 1.19.277 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
983
+ // landed since 1.19.276 with no explicit bump. run 32503053532. (task 1002620)
980
984
  // ---------------------------------------------------------------------------
981
- const CORE_VERSION = '1.19.275'; // CI auto-patch carrier (ADR 0161); see changelog above
985
+ const CORE_VERSION = '1.19.277'; // CI auto-patch carrier (ADR 0161); see changelog above
982
986
 
983
987
  // A namespaced logger so a module's log lines are attributable + consistent.
984
988
  // Usage: const log = api.logger('dev-box'); log.info('mounted');