@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.
- package/.bongos-core.json +37 -27
- package/.claude/skills/builder-sequence/SKILL.md +16 -1
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +3 -0
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/api/openapi.json +55 -3
- package/docs/api-reference.md +3 -2
- package/docs/copy-inventory.md +6 -4
- package/docs/copy-registry.json +23 -5
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +2 -0
- package/modules/hall-ui/public/goal-inbox.js +81 -3
- package/modules/ideas/inbox.js +119 -12
- package/modules/ideas/migrations/ideas_004_lineage_route_index.sql +37 -0
- package/modules/ideas/routes/inbox.js +44 -0
- package/modules/ideas/routing.js +111 -0
- package/modules/lifecycle/ship-card.js +71 -2
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +5 -1
- package/tests/idea_routing.mjs +288 -1
- package/tests/session_boundary_handoff.mjs +184 -0
package/modules/ideas/inbox.js
CHANGED
|
@@ -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
|
-
//
|
|
358
|
-
//
|
|
359
|
-
//
|
|
360
|
-
//
|
|
361
|
-
//
|
|
362
|
-
//
|
|
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:
|
|
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:
|
|
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
|
package/modules/ideas/routing.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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.
|
|
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');
|