@bongos/core 1.19.901 → 1.19.903

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.
Files changed (39) hide show
  1. package/.bongos-core.json +43 -68
  2. package/bin/bongos.js +7 -13
  3. package/docs/module-api-changelog.md +4 -0
  4. package/docs/recipes/bongos-cli-release.md +3 -2
  5. package/docs/recipes/instance-first-run.md +1 -2
  6. package/docs/recipes/local-dev.md +0 -2
  7. package/modules/economy/credits.js +216 -9
  8. package/modules/economy/reward-policy.js +34 -1
  9. package/modules/economy/reward.js +4 -0
  10. package/modules/hall-ui/public/hall-kit.js +11 -5
  11. package/modules/hall-ui/public/profile-charts.js +1 -1
  12. package/modules/ideas/module.json +2 -1
  13. package/modules/ideas/routes/inbox.js +26 -0
  14. package/modules/lifecycle/db-ship.js +15 -0
  15. package/package-lock.json +2 -2
  16. package/package.json +1 -1
  17. package/scripts/gds/api.js +1 -25
  18. package/scripts/gds/build-cli-package.js +17 -35
  19. package/scripts/gds/claude-materialize.js +1 -1
  20. package/scripts/gds/cli-lib.js +5 -127
  21. package/scripts/gds/go-live.js +1 -1
  22. package/scripts/gds/login.js +1 -1
  23. package/scripts/gds/plain-cards.js +0 -8
  24. package/scripts/gds/ship-land.js +2 -18
  25. package/src/module-api.js +1 -1
  26. package/tests/bongos_dispatch.mjs +4 -4
  27. package/tests/claude_materialize.mjs +1 -1
  28. package/tests/cli_package.mjs +1 -4
  29. package/tests/cli_surface.mjs +1 -88
  30. package/tests/hall_kit_charts.mjs +38 -0
  31. package/tests/idea_descent_ship_credit.mjs +481 -0
  32. package/tests/plain_cards.mjs +2 -2
  33. package/tests/rank_tier_single_source.mjs +3 -5
  34. package/scripts/gds/box-connect-lib.js +0 -550
  35. package/scripts/gds/code.js +0 -105
  36. package/scripts/gds/shell.js +0 -102
  37. package/tests/bongos_box_connect.mjs +0 -166
  38. package/tests/box_scope_predicate.mjs +0 -117
  39. package/tests/cli_box_scope.mjs +0 -92
@@ -9,7 +9,7 @@
9
9
  // (db.applyGrade/confirmTask/upsertSessionRecordWithReward) resolves these
10
10
  // through the `reward` kernel PORT rather than importing this module.
11
11
 
12
- const { pool, branding } = require('../../src/module-api');
12
+ const { pool, branding, resolveOptional } = require('../../src/module-api');
13
13
  const { priceModelUsage, PRICES } = require('../../src/module-api').llmPricing; // cost-plus session reward (task 1005)
14
14
  const achievements = require('./achievements');
15
15
 
@@ -157,7 +157,7 @@ const IDEA_GOVERNANCE_PASS_FLOOR = 30; // owner: floor ~30 — see the note on
157
157
  // Re-exported below, so this module is still the one place reward resolution lives
158
158
  // (ADR 0146) and every existing caller is unchanged.
159
159
  const {
160
- REWARD_MARGIN_PCT, REWARD_MODE_DEFAULT, REWARD_MODE_COST_PLUS_ONLY, rewardPolicy,
160
+ REWARD_MARGIN_PCT, REWARD_MODE_DEFAULT, REWARD_MODE_COST_PLUS_ONLY, IDEA_DESCENT_DECAY, rewardPolicy,
161
161
  } = require('./reward-policy');
162
162
 
163
163
  // Per-instance reward policy, resolved over the code defaults. Stays HERE, not in
@@ -502,6 +502,40 @@ async function awardIdeaFullPassCredit(client, { ideaId, authorId, completenessS
502
502
  };
503
503
  }
504
504
 
505
+ // descentPayoutMaxDepth — the deepest hop that can still pay a single credit.
506
+ //
507
+ // The decayed share is base × factor × decay^d, and the best case for factor is
508
+ // 1 (a 100%-complete Full Idea). Once that rounds to 0 no deeper ancestor can
509
+ // ever be paid, whatever its grade, so walking further is pure round trips.
510
+ // Returned as a DEPTH CAP for the walk rather than a break in the payout loop,
511
+ // because the round trips happen inside the walk, not in the loop.
512
+ //
513
+ // Derived, never picked: at the default half it comes out at 4, and a steeper
514
+ // rate gives a smaller number. DESCENT_MAX_DEPTH remains the outer backstop.
515
+ function descentPayoutMaxDepth(decay, base = IDEA_TASK_SHIP_BASE, cap = 64) {
516
+ if (!(decay > 0)) return 0;
517
+ for (let d = 1; d <= cap; d += 1) {
518
+ if (Math.round(base * Math.pow(decay, d)) <= 0) return d - 1;
519
+ }
520
+ return cap;
521
+ }
522
+
523
+ // ideaGradeFactor — what one shipped task under an idea is worth, BEFORE depth.
524
+ //
525
+ // A Full Idea earns in proportion to its completeness; a Quick or Spark idea
526
+ // (no completeness score) earns the flat quick factor. A full idea with no score
527
+ // yet also falls back to the quick factor rather than 0 — the work shipped, and
528
+ // the idea is at least quick-grade.
529
+ //
530
+ // EXTRACTED so the direct payout and the decayed descent payout below cannot
531
+ // disagree about what an idea's work is worth: the two differ only by the decay
532
+ // term, and a second copy of this clamp would make them differ by more.
533
+ function ideaGradeFactor(grade, completeness) {
534
+ return (grade === 'full' && completeness != null && Number.isFinite(Number(completeness)))
535
+ ? Math.max(0, Math.min(1, Number(completeness) / 100))
536
+ : QUICK_IDEA_FACTOR;
537
+ }
538
+
505
539
  // Stream 1 (ADR 0172 / goal 1000067 IC4): as each task descended from an idea
506
540
  // SHIPS, pay the idea's author IDEA_TASK_SHIP_BASE credits, SCALED by the idea's
507
541
  // grade — a Full Idea by its completeness, a Quick/Spark idea by a flat factor.
@@ -560,13 +594,7 @@ async function awardIdeaTaskShipCredit(client, { taskId, config = rewardConfig()
560
594
  return { credited: 0, reason: 'no_source_idea', ideaId: null, authorId: null };
561
595
  }
562
596
  const { idea_id: ideaId, captured_by: authorId, grade, completeness, developed_by: developedBy } = rows[0];
563
- // Grade factor: a Full Idea earns in proportion to its completeness; a Quick
564
- // or Spark idea (no completeness score) earns the flat quick factor. A full
565
- // idea with no score yet also falls back to the quick factor rather than 0 —
566
- // the work shipped, the idea is at least quick-grade.
567
- const factor = (grade === 'full' && completeness != null && Number.isFinite(Number(completeness)))
568
- ? Math.max(0, Math.min(1, Number(completeness) / 100))
569
- : QUICK_IDEA_FACTOR;
597
+ const factor = ideaGradeFactor(grade, completeness);
570
598
  const delta = Math.round(IDEA_TASK_SHIP_BASE * factor);
571
599
  if (delta <= 0) {
572
600
  return { credited: 0, reason: 'zero_after_scaling', ideaId: Number(ideaId), authorId: Number(authorId) };
@@ -592,6 +620,184 @@ async function awardIdeaTaskShipCredit(client, { taskId, config = rewardConfig()
592
620
  : { credited: 0, reason: 'already_paid', ideaId: Number(ideaId), authorId: Number(authorId) };
593
621
  }
594
622
 
623
+ // awardIdeaDescentShipCredit — the same stream 1 payout, for the origins the
624
+ // shipping task descends from RATHER THAN the one it was promoted from
625
+ // (task 1004088, BV2.R09; ADR 0312, spec decisions 6 + 7 + 16).
626
+ //
627
+ // WHAT WAS MISSING. awardIdeaTaskShipCredit above pays the idea whose
628
+ // ideas_task_lineage row names THIS task. That is one hop. A task split off a
629
+ // task an idea produced, a cascade its ship fired, a child of a child — all of
630
+ // them ship and pay the origin nothing, because the origin is not on their
631
+ // lineage row and never will be. The idea's descent grew; its ledger did not.
632
+ //
633
+ // THE RULE: full share at the direct task, `decay` per hop below it. At the
634
+ // default half, an unbounded descent is worth at most twice the direct task, so
635
+ // the series converges and no hop limit has to be invented. The rate is a
636
+ // per-instance dial (config.ideaDescentDecay; modules/economy/reward-policy.js).
637
+ //
638
+ // THE BOUNDARY THAT STOPS FARMING (spec decision 7): DESCENDANT WORK pays the
639
+ // origin; a separate thought that merely cites it does not. Nothing here reads
640
+ // an idea→idea relation — not merged_into_idea_id, not the trail's merge follow.
641
+ // It walks ideas_task_descent, which carries only task→task edges the system
642
+ // derived from a cascade, a split or a fold. That asymmetry is deliberate and is
643
+ // the difference between the two surfaces: the descent DISPLAYS more than the
644
+ // payout COUNTS.
645
+ //
646
+ // ONE PAYMENT PER ORIGIN PER SHIP, AT THE SHORTEST DEPTH. A merge makes the
647
+ // descent a DAG, so one origin can sit above a shipping task by two routes of
648
+ // different length, and two ancestors can share a source idea. Each origin is
649
+ // therefore resolved once, at the smallest depth — the same rule the walk itself
650
+ // applies, for the same reason: at two depths it would be paid twice for one
651
+ // piece of work. The reason key is already per (idea, task), so the ledger would
652
+ // have caught the duplicate; resolving it here means the RETURNED numbers are
653
+ // right too, rather than depending on which booking ran first.
654
+ //
655
+ // FAIL-SOFT AROUND THE WALK, NOT AROUND THE MONEY. If the ideas module is absent
656
+ // (no provider) or has no edges yet, this returns nothing paid and the direct
657
+ // payout above is untouched — which is exactly today's behaviour, and is what
658
+ // "existing first-hop behaviour preserved" means. A walk that THROWS is a
659
+ // different matter and is left to propagate: this runs inside the ship
660
+ // transaction beside the direct credit, and silently swallowing a failure here
661
+ // would under-pay an origin with no record that it happened.
662
+ //
663
+ // client — pg client / pool (transaction participant)
664
+ // taskId — the task that just shipped
665
+ // skipIdeaIds — origins already paid for this ship at depth 0, so the direct
666
+ // payout always wins over a decayed one for the same idea
667
+ // config — resolved reward policy; gates on config.ideatorCredit
668
+ //
669
+ // Returns { credited, reason, payments: [{ ideaId, depth, delta, payees }] }.
670
+ async function awardIdeaDescentShipCredit(client, { taskId, skipIdeaIds = [], config = rewardConfig() } = {}) {
671
+ if (config && config.ideatorCredit === false) {
672
+ return { credited: 0, reason: 'ideator_lane_off', payments: [] };
673
+ }
674
+ // BOUNDED HERE TOO, not only in rewardPolicy(). This is a PORT method: the
675
+ // reward port hands it to any module that resolves it, and a caller may pass
676
+ // its own `config` object that never went through the resolver. A rate of 1
677
+ // or more does not converge — the comment on IDEA_DESCENT_DECAY promises it
678
+ // cannot happen, and a promise the function does not enforce is a promise the
679
+ // next caller breaks. Out of range falls back to the default, exactly as the
680
+ // resolver does, rather than throwing inside a ship transaction.
681
+ const rawDecay = (config && config.ideaDescentDecay) == null
682
+ ? NaN
683
+ : Number(config.ideaDescentDecay);
684
+ const decay = Number.isFinite(rawDecay) && rawDecay >= 0 && rawDecay < 1
685
+ ? rawDecay
686
+ : IDEA_DESCENT_DECAY;
687
+ // 0 means "only the direct task pays" — a legal instance setting, and the
688
+ // cheapest possible answer, so it short-circuits before the walk.
689
+ if (!(decay > 0)) return { credited: 0, reason: 'decay_off', payments: [] };
690
+
691
+ // The walk lives in the ideas module and is reached through the switchboard:
692
+ // a module may not require a sibling (ADR 0083 rule 1), and a second copy of
693
+ // a breadth-first shortest-depth walk would drift into a different amount of
694
+ // money. resolveOptional, because an instance running without the ideas
695
+ // module has no descent to follow and must still ship tasks.
696
+ const port = resolveOptional && resolveOptional('ideas.descent');
697
+ if (!port || typeof port.ancestorsOfTask !== 'function') {
698
+ return { credited: 0, reason: 'no_descent_port', payments: [] };
699
+ }
700
+ // DON'T WALK FURTHER THAN THE MONEY REACHES. Past the depth at which even a
701
+ // 100%-complete idea's decayed share stops rounding to a credit, every
702
+ // remaining hop is a round trip that provably pays nobody. The cap is derived
703
+ // from the rate rather than picked: at the default half it is 4, and a
704
+ // shallower rate makes it smaller, not larger. Bounded by the walk's own
705
+ // DESCENT_MAX_DEPTH, which stays the backstop.
706
+ const maxDepth = descentPayoutMaxDepth(decay);
707
+ const ancestors = await port.ancestorsOfTask(taskId, { client, maxDepth });
708
+ if (!ancestors || ancestors.length === 0) {
709
+ return { credited: 0, reason: 'no_ancestors', payments: [] };
710
+ }
711
+
712
+ // The shortest depth at which each ancestor TASK sits above this ship.
713
+ // ancestorsOfTask already returns nearest-first with each node once, so the
714
+ // first sighting is the shortest; the map is here so the lookup below cannot
715
+ // accidentally read a later one.
716
+ const depthByTask = new Map();
717
+ for (const a of ancestors) {
718
+ const key = String(a.id);
719
+ if (!depthByTask.has(key)) depthByTask.set(key, a.depth);
720
+ }
721
+
722
+ // Which idea each ancestor task came from — ONE query, not one per ancestor.
723
+ // DISTINCT ON picks the EARLIEST lineage link per task, which is the same rule
724
+ // awardIdeaTaskShipCredit applies to the direct task (task 1003515 / ADR 0236:
725
+ // a task subsumed from several ideas keeps a row for each, so the read must
726
+ // choose, and it must choose the way the trail and inbox.sourceIdeaForTask do
727
+ // or the ledger and the money name different ideas).
728
+ const { rows } = await client.query(
729
+ `SELECT DISTINCT ON (l.task_id)
730
+ l.task_id,
731
+ l.idea_id,
732
+ i.captured_by,
733
+ COALESCE(g.grade, 'quick') AS grade,
734
+ g.completeness_score AS completeness,
735
+ g.developed_by AS developed_by
736
+ FROM ideas_task_lineage l
737
+ JOIN idea_inbox i ON i.id = l.idea_id
738
+ LEFT JOIN ideas_grades g ON g.idea_id = l.idea_id
739
+ WHERE l.task_id = ANY($1::bigint[])
740
+ ORDER BY l.task_id, l.created_at ASC, l.idea_id ASC`,
741
+ [[...depthByTask.keys()].map(Number)]
742
+ );
743
+
744
+ // Collapse ancestor tasks onto their ORIGINS: two ancestors can share one
745
+ // source idea, and that idea is owed one payment at the shallower of the two.
746
+ const skip = new Set((skipIdeaIds || []).map(String));
747
+ const byIdea = new Map();
748
+ for (const row of rows) {
749
+ if (row.captured_by == null) continue;
750
+ const ideaKey = String(row.idea_id);
751
+ if (skip.has(ideaKey)) continue; // already paid in full at depth 0
752
+ const depth = depthByTask.get(String(row.task_id));
753
+ if (!Number.isInteger(depth) || depth < 1) continue;
754
+ const prev = byIdea.get(ideaKey);
755
+ if (prev && prev.depth <= depth) continue;
756
+ byIdea.set(ideaKey, { row, depth });
757
+ }
758
+
759
+ const payments = [];
760
+ let credited = 0;
761
+ // Nearest origins first, so a partially-booked run resumes in a stable order
762
+ // and the returned list reads the way the descent does.
763
+ const ordered = [...byIdea.values()].sort((a, b) => a.depth - b.depth || Number(a.row.idea_id) - Number(b.row.idea_id));
764
+ for (const { row, depth } of ordered) {
765
+ const { idea_id: ideaId, captured_by: authorId, grade, completeness, developed_by: developedBy } = row;
766
+ // The SAME grade factor the direct payout applies — a Full Idea earns in
767
+ // proportion to its completeness, a Quick or Spark idea the flat factor.
768
+ // Spelled through the shared helper so the two paths cannot disagree about
769
+ // what an idea's work is worth before depth is taken into account.
770
+ const factor = ideaGradeFactor(grade, completeness);
771
+ // ROUNDED ONCE, at the end. Rounding per hop would compound the error, and
772
+ // rounding to zero IS the floor the spec's open question asked about: a
773
+ // descendant stops paying when its decayed share no longer rounds to a
774
+ // credit, which needs no separately-chosen hop limit.
775
+ const delta = Math.round(IDEA_TASK_SHIP_BASE * factor * Math.pow(decay, depth));
776
+ if (delta <= 0) continue;
777
+ const payees = splitIdeaCredit(delta, { authorId, developedBy });
778
+ if (payees.length === 0) continue;
779
+ // THE SAME REASON KEY the direct payout uses — per (idea, task), not per
780
+ // (idea, task, depth). An origin is paid once for one ship however the
781
+ // descent is re-walked, and a re-run after a partial failure is safe.
782
+ const reason = `idea.credit.task_ship:${ideaId}:${taskId}`;
783
+ const gradeLabel = `${grade}${completeness != null ? ` ${Math.round(Number(completeness))}%` : ''}`;
784
+ const { credited: paid, payees: booked } = await bookIdeaSplit(client, {
785
+ payees,
786
+ reason,
787
+ taskId,
788
+ describe: (p) => `Task #${taskId} shipped ${depth} hop${depth === 1 ? '' : 's'} below idea #${ideaId} (${gradeLabel}) → ${p.delta}c to its ${p.role.replace(/_/g, ' ')} (ideator lane stream 1, decayed by depth, BV2.R09/ADR 0312)`,
789
+ });
790
+ credited += paid;
791
+ payments.push({ ideaId: Number(ideaId), depth, delta, credited: paid, payees: booked });
792
+ }
793
+
794
+ return {
795
+ credited,
796
+ reason: credited > 0 ? 'paid' : (payments.length > 0 ? 'already_paid' : 'nothing_to_pay'),
797
+ payments,
798
+ };
799
+ }
800
+
595
801
  // Stream 3 (ADR 0172 / goal 1000067 IC5): a Full Idea that PASSES the Board
596
802
  // Room's ratification vote pays a share of a 100-credit ceiling, scaled by the
597
803
  // idea's R142 completeness — "70%-complete + passes = 70". The ideator lane, so
@@ -1059,6 +1265,7 @@ module.exports = {
1059
1265
  maybeAwardIdeaPromotionBonus,
1060
1266
  awardIdeaFullPassCredit,
1061
1267
  awardIdeaTaskShipCredit,
1268
+ awardIdeaDescentShipCredit,
1062
1269
  awardIdeaGovernancePassCredit,
1063
1270
  // the spark-handoff split (task 1002923 / BV1.R147, ADR 0185)
1064
1271
  splitIdeaCredit,
@@ -28,6 +28,28 @@ const REWARD_MARGIN_PCT = 20;
28
28
  const REWARD_MODE_DEFAULT = 'cost-plus-and-estimate';
29
29
  const REWARD_MODE_COST_PLUS_ONLY = 'cost-plus-only';
30
30
 
31
+ // The DEPTH DECAY on the ideator descent payout (task 1004088, BV2.R09; spec
32
+ // decision 16). A task shipping pays the idea it came from in full; a task
33
+ // shipping one hop BELOW that pays it this fraction, and so on down.
34
+ //
35
+ // WHY 0.5, AND WHY THE SERIES NEEDS NO CAP. At a half per hop an infinite
36
+ // descent is worth at most twice the direct task, so the payout converges on
37
+ // its own and nothing has to pick an arbitrary hop limit. A rate of 1 would not
38
+ // converge — a wide enough descent could pay an origin without bound — so the
39
+ // resolver refuses anything at or above 1 and falls back to the default.
40
+ //
41
+ // WHY IT LIVES HERE. The owner's ruling (interview Q1) is that the rate belongs
42
+ // to the project, not to the platform, so it is a per-instance dial and this is
43
+ // where per-instance reward policy already resolves. It is the SECOND such dial
44
+ // (the ignite floor in modules/ideas/routing.js is the first), and BV2.R05 is
45
+ // the task that gives the two of them one home; putting it in the existing
46
+ // resolver now makes that a move rather than a rewrite.
47
+ //
48
+ // 0 IS A LEGAL SETTING and means "only the direct task pays" — an instance that
49
+ // wants the old first-hop-only behaviour sets it and gets exactly that, with no
50
+ // code path to disable separately.
51
+ const IDEA_DESCENT_DECAY = 0.5;
52
+
31
53
  // PURE. Resolves a raw branding `reward` block over the code defaults. Every field
32
54
  // is validated rather than trusted: a malformed pack degrades to the defaults
33
55
  // instead of poisoning an award.
@@ -39,12 +61,23 @@ function rewardPolicy(rawReward) {
39
61
  const perTaskCredit = mode !== REWARD_MODE_COST_PLUS_ONLY;
40
62
  // Default-on, opt-out-only: only an explicit `false` withholds the ideator lane.
41
63
  const ideatorCredit = r.ideatorCredit !== false;
42
- return { marginPct, usdPeg, mode, perTaskCredit, ideatorCredit };
64
+ // Validated to [0, 1): see IDEA_DESCENT_DECAY above — a rate of 1 or more does
65
+ // not converge, and a negative one would pay a descent backwards.
66
+ // ABSENT IS THE DEFAULT, NOT ZERO — and Number(null) is 0, which is a legal
67
+ // rate meaning "only the direct task pays". Without this guard a pack that
68
+ // simply omits the key, or carries an explicit null, would silently switch
69
+ // the whole descent payout off and look exactly like a deliberate choice.
70
+ const rawDecay = r.ideaDescentDecay == null ? NaN : Number(r.ideaDescentDecay);
71
+ const ideaDescentDecay = Number.isFinite(rawDecay) && rawDecay >= 0 && rawDecay < 1
72
+ ? rawDecay
73
+ : IDEA_DESCENT_DECAY;
74
+ return { marginPct, usdPeg, mode, perTaskCredit, ideatorCredit, ideaDescentDecay };
43
75
  }
44
76
 
45
77
  module.exports = {
46
78
  REWARD_MARGIN_PCT,
47
79
  REWARD_MODE_DEFAULT,
48
80
  REWARD_MODE_COST_PLUS_ONLY,
81
+ IDEA_DESCENT_DECAY,
49
82
  rewardPolicy,
50
83
  };
@@ -28,6 +28,10 @@ module.exports = {
28
28
  awardIdeaFullPassCredit: (...a) => credits.awardIdeaFullPassCredit(...a),
29
29
  // ADR 0172 IC4 — stream 1: pay the idea's author per shipped task, grade-scaled (replaces the 25% slice at ship).
30
30
  awardIdeaTaskShipCredit: (...a) => credits.awardIdeaTaskShipCredit(...a),
31
+ // BV2.R09 / ADR 0312 — the same stream, for the origins the shipping task
32
+ // DESCENDS from rather than the one it was promoted from, decayed by depth.
33
+ // Called right after the direct payout, which always wins for the same idea.
34
+ awardIdeaDescentShipCredit: (...a) => credits.awardIdeaDescentShipCredit(...a),
31
35
  // ADR 0172 IC5 — stream 3: a board-ratified Full Idea pays up to 100, scaled by completeness.
32
36
  // The board's close transaction resolves this port and calls this with its own
33
37
  // `client` (docs/specs/board-room-pass-event-contract.md) — the port is how
@@ -1141,7 +1141,13 @@
1141
1141
  activeDays += 1;
1142
1142
  totalShips += c;
1143
1143
  }
1144
- return { counts, startSun, endOrd, weeks, activeDays, totalShips };
1144
+ // The window's NAME belongs with the window (task 1003621, artist review of 1003583).
1145
+ // The aria summary described it as "the last year" while the caption under the same
1146
+ // grid said "this year" — a rolling 53 weeks and a calendar year are not the same
1147
+ // span, and V3 asks the accessible line and the visible one to state the same fact.
1148
+ // Both now read this.
1149
+ const spanLabel = weeks === 53 ? 'the last year' : `the last ${weeks} week${weeks === 1 ? '' : 's'}`;
1150
+ return { counts, startSun, endOrd, weeks, activeDays, totalShips, spanLabel };
1145
1151
  }
1146
1152
 
1147
1153
  // calendarGridHtml(days, opts) — a year of ship-days as a contribution calendar:
@@ -1168,7 +1174,7 @@
1168
1174
  function calendarGridHtml(days, opts = {}) {
1169
1175
  const win = calendarWindow(days, opts);
1170
1176
  if (!win) return ''; // nothing real to draw → caller's empty state
1171
- const { counts, startSun, endOrd, weeks, activeDays, totalShips } = win;
1177
+ const { counts, startSun, endOrd, weeks, activeDays, totalShips, spanLabel } = win;
1172
1178
  const ordToYmd = calOrdToYmd;
1173
1179
 
1174
1180
  // level 0 = empty; 1..5 map to the anti-gaming daily cap (streak.js caps a day at
@@ -1188,10 +1194,10 @@
1188
1194
  }
1189
1195
 
1190
1196
  // The window is DESCRIBED, not assumed: a caller passing weeks:6 used to be handed
1191
- // an aria label saying "over the last year" (task 1003583).
1192
- const span = weeks === 53 ? 'the last year' : `the last ${weeks} week${weeks === 1 ? '' : 's'}`;
1197
+ // an aria label saying "over the last year" (task 1003583). The phrase now comes from
1198
+ // calendarWindow so the caption beneath the grid can say the same words.
1193
1199
  const summary = opts.ariaLabel
1194
- || `Ships per day over ${span}: ${totalShips} ship${totalShips === 1 ? '' : 's'} across ${activeDays} day${activeDays === 1 ? '' : 's'}.`;
1200
+ || `Ships per day over ${spanLabel}: ${totalShips} ship${totalShips === 1 ? '' : 's'} across ${activeDays} day${activeDays === 1 ? '' : 's'}.`;
1195
1201
  // The legend is aria-hidden: it decorates the bands, it carries no fact the cell
1196
1202
  // titles and the summary do not already state in words.
1197
1203
  const legend = '<div class="kit-cal__key" aria-hidden="true"><span class="kit-cal__key-label">less</span>'
@@ -284,7 +284,7 @@
284
284
  // The scalars in words, in the .pchart__read slot the other figures use for their
285
285
  // crosshair readout — here it is static because the calendar's reading is per-cell.
286
286
  const read = `Current streak ${cur} day${cur === 1 ? '' : 's'} \u00b7 longest ${lng} \u00b7 `
287
- + `${active} day${active === 1 ? '' : 's'} shipped this year`;
287
+ + `${active} day${active === 1 ? '' : 's'} shipped in ${win ? win.spanLabel : 'the last year'}`;
288
288
  const html = `<figure class="pchart" id="${esc(id)}">
289
289
  <figcaption class="pchart__title">Your shipping year</figcaption>
290
290
  <p class="pchart__read">${esc(read)}</p>
@@ -17,7 +17,8 @@
17
17
  ]
18
18
  },
19
19
  "provides": [
20
- "ideas.capture"
20
+ "ideas.capture",
21
+ "ideas.descent"
21
22
  ],
22
23
  "consumes": [
23
24
  "reward",
@@ -39,6 +39,8 @@ const inbox = require('../inbox');
39
39
  // own file because inbox.js is AT the size ratchet and because a task->task
40
40
  // walk is a different relation from the idea->task trail.
41
41
  const { attachDescentToTrail } = require('../trail-descent');
42
+ // The walk itself, published as the `ideas.descent` port below.
43
+ const descent = require('../descent');
42
44
  // Full Idea capture (task 1002919 / BV1.R143): the template validator (R141)
43
45
  // and the deterministic scorer (R142) — both pure, both walk the declaration.
44
46
  const { validateFieldValues, templateFor, fieldOrder } = require('../templates');
@@ -61,10 +63,34 @@ const { TRIAGE_NOTE_MAX } = inbox;
61
63
  // hook that runs both in the live server AND when a test rebuilds the GDS
62
64
  // router) and hasProvider-guarded so re-instantiation never trips the seam's
63
65
  // single-provider guard. Mirrors modules/economy/routes/reward.js.
66
+ // The `ideas.descent` kernel PORT (task 1004088, BV2.R09; ADR 0312 + ADR 0093
67
+ // §5). The ideator payout follows the task→task descent and decays by depth, so
68
+ // modules/economy/credits.js has to know how far a shipping task sits below the
69
+ // task an idea was promoted into.
70
+ //
71
+ // WHY A PORT AND NOT A SECOND SQL READ, which is what credits.js does for the
72
+ // lineage. A cross-table SQL read across the module wall is allowed and credits.js
73
+ // already relies on that; a cross-module require is not. But the descent is not a
74
+ // read, it is a WALK — breadth-first with a visited set, each node at its shortest
75
+ // depth — and a second copy of that in economy would be free to drift from this
76
+ // one. The payout decays by the depth the walk returns, so a drift between the two
77
+ // copies is not a rendering difference, it is a different amount of money. One
78
+ // implementation, reached through the switchboard.
79
+ //
80
+ // Registered here beside `ideas.capture` for the same reason: the route factory is
81
+ // the one boot hook that runs both in the live server and when a test rebuilds the
82
+ // GDS router. hasProvider-guarded so re-instantiation never trips the single-
83
+ // provider guard.
64
84
  function registerSeams() {
65
85
  if (!api.hasProvider('ideas.capture')) {
66
86
  api.registerProvider('ideas.capture', inbox.captureIdea);
67
87
  }
88
+ if (!api.hasProvider('ideas.descent')) {
89
+ api.registerProvider('ideas.descent', {
90
+ ancestorsOfTask: descent.ancestorsOfTask,
91
+ descendantsOfTask: descent.descendantsOfTask,
92
+ });
93
+ }
68
94
  }
69
95
 
70
96
  // The author-facing shape of a ratification window this request just opened —
@@ -371,11 +371,26 @@ async function awardLandingCredit(client, { taskId, creditedBuilderId, creditsRe
371
371
  const ideaBonus = reward.awardIdeaTaskShipCredit
372
372
  ? await reward.awardIdeaTaskShipCredit(client, { taskId })
373
373
  : null;
374
+ // BV2.R09 (ADR 0312): the same stream, for the origins this task DESCENDS
375
+ // from. The direct payout above pays the idea whose lineage row names this
376
+ // task; this pays the ideas above it in the task→task descent, decayed by
377
+ // depth. Its skipIdeaIds carries whatever the direct payout just settled, so
378
+ // an idea that is both the direct source AND an ancestor's source is paid the
379
+ // full share once rather than the full share plus a decayed one.
380
+ // Guarded the same way — an older economy module without this export, or an
381
+ // instance with no ideas module, simply pays nothing extra.
382
+ const ideaDescent = reward.awardIdeaDescentShipCredit
383
+ ? await reward.awardIdeaDescentShipCredit(client, {
384
+ taskId,
385
+ skipIdeaIds: ideaBonus && ideaBonus.ideaId != null ? [ideaBonus.ideaId] : [],
386
+ })
387
+ : null;
374
388
  return {
375
389
  creditsAwarded: credit.creditsAwarded,
376
390
  creditsRaw: credit.raw,
377
391
  creditsMultiplier: credit.multiplier,
378
392
  ideaBonus,
393
+ ideaDescent,
379
394
  };
380
395
  }
381
396
 
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.901",
3
+ "version": "1.19.903",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.901",
9
+ "version": "1.19.903",
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.901",
3
+ "version": "1.19.903",
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",
@@ -18,9 +18,7 @@
18
18
  // on a 2xx, 1 on any non-2xx or transport error, 2 on bad invocation.
19
19
 
20
20
  const fs = require('node:fs');
21
- const { cliClient, arg, stripBom, resolveQuiet, isBoxScope, printBoxScopeGuidanceAndExit, cliExit, repairMsysMangledPath,
22
- instanceDevBoxSupport, devBoxUnavailableMessage } = require('./cli-lib');
23
- const { plainCardLines } = require('./plain-cards');
21
+ const { cliClient, arg, stripBom, resolveQuiet, cliExit, repairMsysMangledPath } = require('./cli-lib');
24
22
 
25
23
 
26
24
  (async () => {
@@ -77,31 +75,9 @@ const { plainCardLines } = require('./plain-cards');
77
75
  return cliExit(1); // task 1002734: never process.exit() after a request
78
76
  }
79
77
  if (!quiet) console.error(`${method} ${urlPath} → ${res.status}`);
80
- // task 1501: on a dev box the baked session is box-scoped (ADR 0053) and every
81
- // non-bootstrap call (e.g. /me, /inbox) returns 403 box_scope. We pass
82
- // allowUnauthenticated above (so a 401 stays a plain error, not the setup
83
- // short-circuit), which ALSO suppresses apiCall's own box_scope guidance — so
84
- // surface it here. Without this the raw {error:'box_scope'} body was dumped and
85
- // rank-gated skills (/idea-triage, /blocker-review, …) read the missing `rank`
86
- // as "pre-rank passthrough" and silently did nothing (reported an empty inbox
87
- // on a dev box). The guided message tells the builder to /builder-reauth.
88
- if (isBoxScope(res)) {
89
- printBoxScopeGuidanceAndExit(); // prints the re-auth guidance + exit(10)
90
- }
91
78
  if (res.data !== undefined && res.data !== null) {
92
79
  console.log(JSON.stringify(res.data, null, 2));
93
80
  }
94
- // task 1003730: `bongos box` in the public CLI is this passthrough aimed at
95
- // POST /box/ensure, so on an instance without the dev-box module a builder got a raw
96
- // 404 body and no idea why. api.js stays generic — it just adds the one explanation
97
- // that a /box/ 404 always means — and only for a reader who typed a box command.
98
- if (res.status === 404 && /^\/api\/(?:bongos\/v1|bongos|gds)\/box\b/.test(urlPath)) {
99
- const { available } = await instanceDevBoxSupport();
100
- if (!available) {
101
- console.error(`\n✖ ${devBoxUnavailableMessage()}`);
102
- for (const line of plainCardLines('dev_box_unavailable')) console.error(line);
103
- }
104
- }
105
81
  // task 1002734: process.exit() here aborted node 5/5 on Windows —
106
82
  // "Assertion failed: !(handle->flags & UV_HANDLE_CLOSING), file src\win\async.c"
107
83
  // — AFTER the JSON had already printed, so every caller that checks the exit code