@bongos/core 1.19.380 → 1.19.382

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.
@@ -20,6 +20,7 @@ const db = require('./db');
20
20
  const classifier = require('./task-classifier');
21
21
  const helpRequests = require('./help-requests');
22
22
  const taskRecommendations = require('./task-recommendations');
23
+ const activity = require('./activity');
23
24
 
24
25
  module.exports = {
25
26
  // --- retired doorway leaks (ADR 0093 §3) ---------------------------------
@@ -58,6 +59,12 @@ module.exports = {
58
59
  recentGradesForBuilder: (...a) => db.recentGradesForBuilder(...a),
59
60
  countShippedTasksForBuilder: (...a) => db.countShippedTasksForBuilder(...a),
60
61
  recentShippedTasksByBuilder: (...a) => db.recentShippedTasksByBuilder(...a),
62
+ // builders.js (the profile's per-craft activity log, task 1003281): the paged
63
+ // "what did I do as an engineer / an artist" reads. Separate from the two
64
+ // above because those answer "how much, in total" and these answer "what,
65
+ // by craft" — the axis criterion legibility-activity-by-role asks for.
66
+ activityTasksByBuilder: (...a) => activity.activityTasksByBuilder(...a),
67
+ activityGoalsByBuilder: (...a) => activity.activityGoalsByBuilder(...a),
61
68
  // ADR 0120 part 5: the confirmed tasks a builder owes a rebase on — the same
62
69
  // read the part-3 claim gate uses, surfaced to me.js so the CLI + hall can
63
70
  // show the owed-rebase warning that gates new claims (reward lands on ship).
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.380",
3
+ "version": "1.19.382",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.380",
9
+ "version": "1.19.382",
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.380",
3
+ "version": "1.19.382",
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",
@@ -56,21 +56,78 @@ function encodeProjectPath(absProjectPath) {
56
56
  return String(absProjectPath).replace(/[^a-zA-Z0-9]/g, '-');
57
57
  }
58
58
 
59
+ // listProjectDirs — the default directory lister for the scan fallback below.
60
+ // Returns the immediate subdirectory NAMES of ~/.claude/projects, sorted so a
61
+ // scan is deterministic. Injectable (opts.readdirFn) so the fallback is unit
62
+ // testable without a real home dir.
63
+ function listProjectDirs(projectsRoot) {
64
+ return fs
65
+ .readdirSync(projectsRoot, { withFileTypes: true })
66
+ .filter((d) => d.isDirectory())
67
+ .map((d) => d.name)
68
+ .sort();
69
+ }
70
+
59
71
  // resolveTranscriptPath — find this session's transcript .jsonl. Claude Code
60
72
  // keys the transcript to the project dir of the CWD where the session RUNS, so
61
73
  // a session in a git worktree stores it under the WORKTREE-encoded dir, not the
62
74
  // main checkout. (6D.1 #540 shipped resolving only REPO_ROOT, which silently
63
75
  // missed every worktree session — #551.) We try the session-local dir
64
- // (process.cwd()) first, then REPO_ROOT as a fallback. Pure (exists + base
65
- // injectable for tests). Returns the first existing path, or null.
76
+ // (process.cwd()) first, then REPO_ROOT.
77
+ //
78
+ // task 1002505: those two fast paths NARROW the miss, they do not seal it. The
79
+ // session driving a ship need not be rooted in EITHER dir — a session rooted in
80
+ // another repo can file a task here and ship it (proven on task 1002475, whose
81
+ // orchestrating session lived under a different project's encoded dir, so both
82
+ // probed paths missed, no session_record was written, and the cost-plus reward
83
+ // — the only stream this instance pays, ADR 0146 — never booked). So after the
84
+ // fast paths we SCAN every dir under ~/.claude/projects for <sessionId>.jsonl.
85
+ // A session id is a UUID, so a filename hit is unambiguous and cannot
86
+ // mis-attribute; the scan is one stat per project dir, so it stays cheap.
87
+ //
88
+ // Pure-ish (exists + readdir + base injectable for tests). Pass opts.tried an
89
+ // array and every probed path is appended to it — the caller reports that in a
90
+ // transcript_not_found skip, so a genuine miss still names where it looked. The
91
+ // scan contributes ONE summary line, not one line per dir, so the reason stays
92
+ // readable. Returns the first existing path, or null.
66
93
  function resolveTranscriptPath(sessionId, projectDirs, opts = {}) {
67
94
  const exists = opts.existsFn || fs.existsSync;
95
+ const readdir = opts.readdirFn || listProjectDirs;
68
96
  const base = opts.base || claudeConfigBase();
69
- for (const dir of projectDirs) {
97
+ const tried = Array.isArray(opts.tried) ? opts.tried : null;
98
+ const projectsRoot = path.join(base, 'projects');
99
+ const seen = new Set();
100
+
101
+ const probe = (p, { record = true } = {}) => {
102
+ if (seen.has(p)) return false;
103
+ seen.add(p);
104
+ if (tried && record) tried.push(p);
105
+ return exists(p);
106
+ };
107
+
108
+ for (const dir of projectDirs || []) {
70
109
  if (!dir) continue;
71
- const p = path.join(base, 'projects', encodeProjectPath(path.resolve(dir)), `${sessionId}.jsonl`);
72
- if (exists(p)) return p;
110
+ const p = path.join(projectsRoot, encodeProjectPath(path.resolve(dir)), `${sessionId}.jsonl`);
111
+ if (probe(p)) return p;
112
+ }
113
+
114
+ // Fallback scan. Best-effort: an unreadable/absent projects root is not an
115
+ // error here — it just means the scan finds nothing, same as the fast paths.
116
+ let names;
117
+ try {
118
+ names = readdir(projectsRoot);
119
+ } catch (_) {
120
+ names = null;
121
+ }
122
+ if (!Array.isArray(names)) {
123
+ if (tried) tried.push(`${path.join(projectsRoot, '*')} (could not read the projects dir)`);
124
+ return null;
125
+ }
126
+ for (const name of names) {
127
+ const p = path.join(projectsRoot, name, `${sessionId}.jsonl`);
128
+ if (probe(p, { record: false })) return p;
73
129
  }
130
+ if (tried) tried.push(`${path.join(projectsRoot, '*', `${sessionId}.jsonl`)} (scanned ${names.length} project dirs)`);
74
131
  return null;
75
132
  }
76
133
 
@@ -11,7 +11,7 @@
11
11
  const fs = require('node:fs');
12
12
  const path = require('node:path');
13
13
  const { cliClient, loadSession } = require('./cli-lib');
14
- const { claudeConfigBase, encodeProjectPath, resolveTranscriptPath } = require('./ship-memory-sync.js');
14
+ const { resolveTranscriptPath } = require('./ship-memory-sync.js');
15
15
  const { apiErrorLine } = require('./ship-io.js');
16
16
  const { REPO_ROOT } = require('./ship-state.js');
17
17
 
@@ -117,16 +117,18 @@ async function uploadSessionOnShip(taskId) {
117
117
  // hostile env value with `../` could otherwise point the read elsewhere.
118
118
  const sessionIdSafe = !!sessionId && /^[A-Za-z0-9_-]+$/.test(sessionId);
119
119
  // The transcript is keyed to the session's CWD (the worktree when shipping
120
- // from one), so try that before REPO_ROOT (#551). Reconstruct the exact
121
- // paths resolveTranscriptPath checks so a transcript-not-found skip can name
122
- // precisely where it looked (actionable, not a bare "not found").
120
+ // from one), so try that before REPO_ROOT (#551), then let the resolver scan
121
+ // every ~/.claude/projects dir — a session rooted in ANOTHER repo can drive a
122
+ // ship here, and before task 1002505 that missed both fast paths and silently
123
+ // wrote no session_record at all. resolveTranscriptPath fills pathsTried with
124
+ // exactly what it probed, so a transcript-not-found skip names precisely where
125
+ // it looked (actionable, not a bare "not found") without this file having to
126
+ // re-derive the encoding and drift from it.
123
127
  const dirsTried = [process.cwd(), REPO_ROOT].filter(Boolean);
124
- const pathsTried = sessionIdSafe
125
- ? dirsTried.map((d) =>
126
- path.join(claudeConfigBase(), 'projects', encodeProjectPath(path.resolve(d)), `${sessionId}.jsonl`)
127
- )
128
- : [];
129
- const transcriptPath = sessionIdSafe ? resolveTranscriptPath(sessionId, dirsTried) : null;
128
+ const pathsTried = [];
129
+ const transcriptPath = sessionIdSafe
130
+ ? resolveTranscriptPath(sessionId, dirsTried, { tried: pathsTried })
131
+ : null;
130
132
 
131
133
  const decision = classifyUploadSkip({ sessionId, sessionIdSafe, transcriptPath, pathsTried });
132
134
  if (decision.skip) {
@@ -82,6 +82,11 @@ const FIXTURE_FALLBACKS = [
82
82
  [/^analytics\/builder\/\d+$/, 'analytics__builder'],
83
83
  [/^builders\/\d+\/active-claims$/, 'builders__active-claims'],
84
84
  [/^builders\/\d+\/profile$/, 'builders__profile'],
85
+ // the profile's per-craft activity log (task 1003281). One fixture per SECTION
86
+ // rather than per id, because the section is what changes the row shape; the
87
+ // craft and the page window are query string, which fixture matching ignores.
88
+ [/^builders\/\d+\/activity$/, (q) => (q.section === 'goals' ? 'builders__activity-goals' : 'builders__activity-tasks')],
89
+ [/^inbox\/by-builder\/\d+$/, 'inbox__by-builder'],
85
90
  ];
86
91
 
87
92
  function loadFixture(name) {
@@ -102,12 +107,17 @@ function emptied(v) {
102
107
  return v;
103
108
  }
104
109
 
105
- function fixtureFor(apiPath) {
110
+ // A fallback name may be a plain string or a function of the query — the one
111
+ // case that needs it (task 1003281) is the profile activity log, where the ROW
112
+ // SHAPE is chosen by ?section=, not by the path, so one fixture per path would
113
+ // render goal rows into the tasks ledger.
114
+ function fixtureFor(apiPath, query = {}) {
106
115
  const exact = loadFixture(apiPath.replace(/\//g, '__'));
107
116
  if (exact !== undefined) return exact;
108
117
  for (const [re, name] of FIXTURE_FALLBACKS) {
109
118
  if (re.test(apiPath)) {
110
- const hit = loadFixture(name);
119
+ const resolved = typeof name === 'function' ? name(query) : name;
120
+ const hit = resolved && loadFixture(resolved);
111
121
  if (hit !== undefined) return hit;
112
122
  }
113
123
  }
@@ -141,7 +151,7 @@ app.use((req, res, next) => {
141
151
  if (req.method !== 'GET') {
142
152
  return res.status(501).json({ error: { code: 'preview_readonly', message: 'the preview harness serves fixtures; writes are not wired' } });
143
153
  }
144
- const fix = fixtureFor(apiPath);
154
+ const fix = fixtureFor(apiPath, req.query || {});
145
155
  if (fix === undefined) {
146
156
  console.log(` no fixture: ${apiPath} (add scripts/hall-preview/fixtures/${apiPath.replace(/\//g, '__')}.json)`);
147
157
  return res.status(404).json({ error: { code: 'no_fixture', message: apiPath } });
@@ -545,6 +545,73 @@ module.exports = function buildBuildersRouter() {
545
545
  }
546
546
  });
547
547
 
548
+ // GET /builders/:id/activity — the profile's PER-CRAFT activity log (task
549
+ // 1003281, goal 1000072, criterion legibility-activity-by-role).
550
+ //
551
+ // WHAT IT ADDS OVER /profile. The profile answers "how much has this person
552
+ // shipped" — one count and the last five works, craft-blind. A person's artist
553
+ // track record is a different question from their engineer one, so this route
554
+ // serves ONE section (`tasks` or `goals`) for ONE craft, paged. It EXTENDS the
555
+ // profile surface rather than forking it: the page is the same page, the
556
+ // identity band and the achievement shelf are unchanged, and this fills the
557
+ // ledger below them. The third section of that log — the IDEA TRAIL — is
558
+ // served by the ideas module's GET /inbox/by-builder/:id, because an idea is
559
+ // its domain's row, not a lifecycle one.
560
+ //
561
+ // ONE SECTION PER CALL, not all three in one payload: each ledger pages
562
+ // independently, and a single response carrying three page windows would need
563
+ // three limit/offset pairs on one query string to say anything at all.
564
+ //
565
+ // rank: any-builder, NOT owner-only — a track record is a thing other people
566
+ // read; that is what makes the profile a portfolio rather than a dashboard, and
567
+ // it is the same posture /profile, /builders/roster and the leaderboard already
568
+ // take toward the same rows. The per-viewer filtering that DOES matter is the
569
+ // private-goal wall below, and it is applied to the DATA, not to the caller.
570
+ //
571
+ // But, unlike /profile directly above, this read IS gated to a sign-in.
572
+ // The goals half is viewer-dependent: a private goal's title is exactly what
573
+ // ADR 0112 says a non-member must not learn, so the read needs to know who is
574
+ // asking. Serving it publicly would mean either leaking private goals or
575
+ // silently omitting the viewer's own — both worse than a sign-in. The tasks
576
+ // half is the same already-public shipped-work data /profile returns.
577
+ router.get('/builders/:id/activity', auth.requireBuilder, async (req, res) => {
578
+ const targetId = parseId(req, res, { code: 'bad_builder_id', positiveInt: true });
579
+ if (targetId === null) return;
580
+ const section = req.query.section === 'goals' ? 'goals' : 'tasks';
581
+ // An unknown craft is normalized to "no filter" inside the module read
582
+ // rather than 400ing here — a stale bookmark should show the whole record,
583
+ // not an error card in the middle of a profile.
584
+ const role = typeof req.query.role === 'string' && req.query.role ? req.query.role : null;
585
+ const limit = Number(req.query.limit);
586
+ const offset = Number(req.query.offset);
587
+ try {
588
+ const b = await db.getBuilderById(targetId);
589
+ if (!b) return res.fail('builder_not_found', 404);
590
+ const viewer = req.builder || {};
591
+ const out = section === 'goals'
592
+ ? await lc().activityGoalsByBuilder(targetId, {
593
+ role,
594
+ viewerId: viewer.id ?? null,
595
+ viewerIsArchon: viewer.rank === 'archon',
596
+ limit,
597
+ offset,
598
+ })
599
+ : await lc().activityTasksByBuilder(targetId, { role, limit, offset });
600
+ // Viewer-dependent (the goals half) and cheap to recompute — never cached.
601
+ res.set('Cache-Control', 'no-store');
602
+ res.json({
603
+ builder_id: targetId,
604
+ section,
605
+ role: role || null,
606
+ rows: out.rows,
607
+ total: out.total,
608
+ });
609
+ } catch (err) {
610
+ log.error('[gds] GET /builders/:id/activity', err);
611
+ res.fail('activity_read_failed', { status: 500, message: 'internal error' });
612
+ }
613
+ });
614
+
548
615
  // GET /builders/me/pending-rank-change — V3.R28 / #249 ceremony signal #3.
549
616
  //
550
617
  // Returns the calling builder's most recent rank-change credit_log row, or
package/src/module-api.js CHANGED
@@ -1200,8 +1200,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
1200
1200
  // landed since 1.19.378 with no explicit bump. run 33026467245. (task 1002620)
1201
1201
  // 1.19.380 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1202
1202
  // landed since 1.19.379 with no explicit bump. run 33085516721. (task 1002620)
1203
+ // 1.19.381 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1204
+ // landed since 1.19.380 with no explicit bump. run 33088290961. (task 1002620)
1205
+ // 1.19.382 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1206
+ // landed since 1.19.381 with no explicit bump. run 33094545251. (task 1002620)
1203
1207
  // ---------------------------------------------------------------------------
1204
- const CORE_VERSION = '1.19.380'; // CI auto-patch carrier (ADR 0161); see changelog above
1208
+ const CORE_VERSION = '1.19.382'; // CI auto-patch carrier (ADR 0161); see changelog above
1205
1209
 
1206
1210
  // A namespaced logger so a module's log lines are attributable + consistent.
1207
1211
  // Usage: const log = api.logger('dev-box'); log.info('mounted');