@bongos/core 1.19.496 → 1.19.498
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 +47 -27
- package/.claude/skills/planning-session/SKILL.md +3 -1
- package/docs/copy-inventory.md +70 -56
- package/docs/copy-registry.json +201 -74
- package/docs/file-map.md +1 -1
- package/docs/module-api-changelog.md +4 -0
- package/modules/discord/discord-inbound.js +73 -1
- package/modules/hall-ui/public/ideas.css +5 -0
- package/modules/hall-ui/public/ideas.html +57 -0
- package/modules/hall-ui/public/ideas.js +173 -0
- package/modules/hall-ui/public/task.js +87 -3
- package/modules/ideas/inbox.js +8 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/capture.js +151 -2
- package/scripts/gds/seed-bongos-v1-idea-routing-tasks.js +25 -1
- package/scripts/gds/seed-verify-criteria.js +69 -0
- package/src/module-api.js +1 -1
- package/tests/capture_goal_routing.mjs +242 -0
- package/tests/discord_inbound.mjs +89 -1
- package/tests/hall_record_world.mjs +71 -3
- package/tests/idea_routing.mjs +33 -0
- package/tests/idea_spark_hall.mjs +136 -2
- package/tests/planning_session_skill.mjs +57 -0
- package/tests/seed_verify_criteria.mjs +149 -0
package/docs/file-map.md
CHANGED
|
@@ -185,7 +185,7 @@
|
|
|
185
185
|
│ ├── box.js ← (#598/ADR 0031) per-builder dev box lifecycle CLI: provision·park·wake·deprovision·sweep-idle·sweep-dormant·status (dry-run default, --apply to act; rank-gated)
|
|
186
186
|
│ ├── smoke-box.sh ← (#598) hermetic box-lifecycle smoke (no DB/DO/network): runs tests/boxes.mjs + tests/cf_tunnel.mjs (#771 named tunnel) + route-rank + module-load
|
|
187
187
|
│ ├── recall.js ← `/recall` CLI: two-call progressive-disclosure search (summary → `--expand <id>`) over Bongos retrieval layer (ADR 0060); `node scripts/gds/recall.js <query>`
|
|
188
|
-
│ ├── capture.js ← file an idea_inbox row from the CLI (the ideas.capture kernel port); backs the "capture a tangent" flow across skills. Task 1002927 / R151 taught it the GRADES: `--full --fields-file f.json` files a Full Idea, `--spark` is capture-then-mark (two requests — the route refuses a pre-marked capture), and after a plain quick capture it OFFERS to develop further. That offer is TTY-gated (`--no-prompt` too): capture.js is run by agents with no terminal and reads its body from stdin, so an unguarded prompt would hang every non-interactive caller
|
|
188
|
+
│ ├── capture.js ← file an idea_inbox row from the CLI (the ideas.capture kernel port); backs the "capture a tangent" flow across skills. Task 1002927 / R151 taught it the GRADES: `--full --fields-file f.json` files a Full Idea, `--spark` is capture-then-mark (two requests — the route refuses a pre-marked capture), and after a plain quick capture it OFFERS to develop further. That offer is TTY-gated (`--no-prompt` too): capture.js is run by agents with no terminal and reads its body from stdin, so an unguarded prompt would hang every non-interactive caller. Task 1003070 / R09 (goal 1000071) taught it capture-time ROUTING: `--goal N` sends goal_id, `--goal none` opts out explicitly, and with neither it defaults to the caller's active claim's goal, else prints a one-line R07 suggester hint
|
|
189
189
|
│ ├── spark.js ← (tasks 1002920 / R144 + 1002921 / R145 + 1002922 / R146) the whole spark handoff from the terminal: mark one of your OWN quick ideas as a Big Idea Spark (`--undo` withdraws it), `--list` is the SPARK QUEUE, `--template` prints the five Full Idea fields, `--develop <id> --fields-file <json>` is the PICKUP (any builder; author + developer both recorded). Author-only server-side for the mark (it is the consent); its own command because sparking acts on an EXISTING idea, which is why capture.js refuses grade='spark'
|
|
190
190
|
│ ├── stale-worktrees.js ← (task 1003365) the per-claim worktrees whose branch already landed on origin/main or is gone — pure git facts, no API; `--line` is the SessionStart probe, `/worktree-clean` walks the list through worktree.js remove
|
|
191
191
|
│ ├── worktree-claim-guard.js ← (task 1002579) the PURE decision behind worktree-per-claim, enforced at claim time: claiming in the MAIN CHECKOUT is refused (escape hatch `--here` → loud warning), a linked worktree passes, an unclassifiable tree fails open. Stops the ship-time "the main checkout is on <branch>, not main" strand at its cause
|
|
@@ -1429,5 +1429,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
1429
1429
|
landed since 1.19.494 with no explicit bump. run 33766103175. (task 1002620)
|
|
1430
1430
|
1.19.496 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1431
1431
|
landed since 1.19.495 with no explicit bump. run 33766363834. (task 1002620)
|
|
1432
|
+
1.19.497 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1433
|
+
landed since 1.19.496 with no explicit bump. run 33767382661. (task 1002620)
|
|
1434
|
+
1.19.498 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1435
|
+
landed since 1.19.497 with no explicit bump. run 33768753719. (task 1002620)
|
|
1432
1436
|
---------------------------------------------------------------------------
|
|
1433
1437
|
```
|
|
@@ -20,6 +20,25 @@
|
|
|
20
20
|
//
|
|
21
21
|
// Requires (go-live): the Message Content PRIVILEGED intent (to read message
|
|
22
22
|
// text) + DISCORD_IDEAS_CHANNEL_ID. Inert until both are set.
|
|
23
|
+
//
|
|
24
|
+
// ROUTING HINTS, NEVER ROUTING (task 1003068, BV1.R12; goal 1000071 criterion
|
|
25
|
+
// C5, deliberately not C1/C3). Unlike #bugs (discord-bugs.js, R11), this vector
|
|
26
|
+
// never routes: a Discord author is outside the trust boundary (ADR 0016,
|
|
27
|
+
// ADR 0032), so their message may SUGGEST a goal but must never BIND one. The
|
|
28
|
+
// suggester (modules/lifecycle/goal-suggest.js, resolved through the same
|
|
29
|
+
// `lifecycle` port #bugs already consumes) supplies a candidate goal; its id
|
|
30
|
+
// rides onto the row as the advisory `suggested_goal_id` hint (core_222) and its
|
|
31
|
+
// `version_id` as `suggested_version` — both read-only guesses for /idea-triage
|
|
32
|
+
// (R13), never `goalId`, which is what would make captureIdea actually route.
|
|
33
|
+
//
|
|
34
|
+
// suggested_version DEFAULTS even without a goal match: when the classifier
|
|
35
|
+
// finds no confident goal, `suggested_version` falls back to whichever open
|
|
36
|
+
// version most recently started `status='building'` — the version a filer is
|
|
37
|
+
// overwhelmingly likely to mean when they post right now, absent any other
|
|
38
|
+
// signal. A matched goal's OWN version always wins over the default (the two
|
|
39
|
+
// travel together elsewhere in this goal — a routed capture binds to the
|
|
40
|
+
// goal's version, never a caller-supplied one), so the fallback only fires
|
|
41
|
+
// when there is truly nothing more specific to say.
|
|
23
42
|
|
|
24
43
|
// captureIdea is no longer a doorway export (retired at BV1.R78 / ADR 0093 §5).
|
|
25
44
|
// We resolve the `ideas.capture` kernel PORT the ideas module registers — the
|
|
@@ -31,6 +50,7 @@ const auth = require('./auth');
|
|
|
31
50
|
const attachments = require('./bug-attachments');
|
|
32
51
|
const { createMessageAggregator } = require('./discord-message-buffer');
|
|
33
52
|
const { LIMITS } = api;
|
|
53
|
+
const log = api.logger('discord');
|
|
34
54
|
|
|
35
55
|
// Auto-pairing window for #ideas (task #979) — same default as #bugs. Env
|
|
36
56
|
// DISCORD_IDEAS_PAIR_WINDOW_MS overrides.
|
|
@@ -68,10 +88,37 @@ const realDeps = {
|
|
|
68
88
|
// Lazy: resolve the `ideas.capture` port at CALL time, so the ideas module's
|
|
69
89
|
// registration (at its route-factory mount) need not precede this module's load.
|
|
70
90
|
captureIdea: (...a) => api.resolve('ideas.capture')(...a),
|
|
91
|
+
// resolveOptional, not resolve: a vanilla instance without the lifecycle
|
|
92
|
+
// module has no goals to suggest, and that is a reason to skip the hint —
|
|
93
|
+
// never a reason to lose the idea (the discord-bugs.js precedent).
|
|
94
|
+
suggestGoalForCapture: async (...a) => {
|
|
95
|
+
const lifecycle = api.resolveOptional && api.resolveOptional('lifecycle');
|
|
96
|
+
if (!lifecycle || !lifecycle.suggestGoalForCapture) return null;
|
|
97
|
+
return lifecycle.suggestGoalForCapture(...a);
|
|
98
|
+
},
|
|
99
|
+
// Same resolveOptional posture — a vanilla instance without lifecycle has no
|
|
100
|
+
// versions to default to, which is a reason to skip the default, never the idea.
|
|
101
|
+
listVersions: async () => {
|
|
102
|
+
const lifecycle = api.resolveOptional && api.resolveOptional('lifecycle');
|
|
103
|
+
if (!lifecycle || !lifecycle.listVersions) return [];
|
|
104
|
+
return lifecycle.listVersions();
|
|
105
|
+
},
|
|
71
106
|
insertAuditLog: db.insertAuditLog,
|
|
72
107
|
persistImages: attachments.persistImages,
|
|
73
108
|
};
|
|
74
109
|
|
|
110
|
+
// mostRecentBuildingVersionId — PURE: the id of whichever `status='building'`
|
|
111
|
+
// version most recently started, or null. The suggested_version fallback for a
|
|
112
|
+
// filing whose classifier found no confident goal (see header). `started_at` is
|
|
113
|
+
// a timestamp string/Date; String() comparison is wrong for it, so this compares
|
|
114
|
+
// parsed dates explicitly rather than relying on default sort.
|
|
115
|
+
function mostRecentBuildingVersionId(versions) {
|
|
116
|
+
const building = (Array.isArray(versions) ? versions : []).filter((v) => v && v.status === 'building');
|
|
117
|
+
if (building.length === 0) return null;
|
|
118
|
+
building.sort((a, b) => new Date(b.started_at) - new Date(a.started_at));
|
|
119
|
+
return building[0].id;
|
|
120
|
+
}
|
|
121
|
+
|
|
75
122
|
// File one inbound message as an idea. NEVER throws (the caller is a gateway
|
|
76
123
|
// event handler). Returns a small status object for logging/tests.
|
|
77
124
|
async function recordInboundIdea({ discordUserId, content, imageUrls = [], messageId }, deps = realDeps) {
|
|
@@ -114,12 +161,34 @@ async function recordInboundIdea({ discordUserId, content, imageUrls = [], messa
|
|
|
114
161
|
}
|
|
115
162
|
bodyMd = bodyMd.slice(0, LIMITS.DESCRIPTION);
|
|
116
163
|
|
|
164
|
+
// HINTS ONLY (see header). Neither lookup throws into the filing — a
|
|
165
|
+
// suggester/version-list outage must not cost the idea, only the hints.
|
|
166
|
+
let hint = null;
|
|
167
|
+
try {
|
|
168
|
+
hint = await deps.suggestGoalForCapture({ title: derived.title, body: derived.bodyMd });
|
|
169
|
+
} catch (e) {
|
|
170
|
+
log.warn({ err: e && e.message }, 'idea goal suggestion failed — filing without a goal hint');
|
|
171
|
+
}
|
|
172
|
+
let suggestedVersion = hint ? hint.version_id : null;
|
|
173
|
+
if (!suggestedVersion) {
|
|
174
|
+
try {
|
|
175
|
+
suggestedVersion = mostRecentBuildingVersionId(await deps.listVersions());
|
|
176
|
+
} catch (e) {
|
|
177
|
+
log.warn({ err: e && e.message }, 'default-version lookup failed — filing without a version hint');
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
117
181
|
const idea = await deps.captureIdea({
|
|
118
182
|
title: derived.title,
|
|
119
183
|
bodyMd,
|
|
120
184
|
capturedBy: builder ? builder.id : null,
|
|
121
185
|
// kind left null → inbox.captureIdea auto-classifies. We never pass
|
|
122
186
|
// 'criterion-proposal' (the rank-gated kind) from this path.
|
|
187
|
+
// suggestedGoalId/suggestedVersion are ADVISORY-ONLY columns — this call
|
|
188
|
+
// deliberately never names the routing parameter (see header + F12 in
|
|
189
|
+
// tests/filing_abuse_matrix.mjs, which greps this exact call for it).
|
|
190
|
+
suggestedGoalId: hint ? hint.id : null,
|
|
191
|
+
suggestedVersion,
|
|
123
192
|
});
|
|
124
193
|
|
|
125
194
|
await auditDiscordBot(deps, {
|
|
@@ -144,4 +213,7 @@ function createIdeaAggregator({ onFlush, windowMs = pairWindowMs(), timers = glo
|
|
|
144
213
|
return createMessageAggregator({ onFlush, windowMs, timers });
|
|
145
214
|
}
|
|
146
215
|
|
|
147
|
-
module.exports = {
|
|
216
|
+
module.exports = {
|
|
217
|
+
deriveIdea, recordInboundIdea, createIdeaAggregator, pairWindowMs, realDeps,
|
|
218
|
+
mostRecentBuildingVersionId,
|
|
219
|
+
};
|
|
@@ -74,6 +74,11 @@
|
|
|
74
74
|
.fi-err { margin: var(--sp-sm) 0 0; max-width: var(--measure); font-size: 14px; line-height: 1.5; color: var(--danger); }
|
|
75
75
|
.fi-err[data-visible="0"] { display: none; }
|
|
76
76
|
|
|
77
|
+
/* The quick-filing form's outcome line (task 1003069 / R08) — a routed capture
|
|
78
|
+
names the task it landed in, so its link takes the world's 24px row recipe. */
|
|
79
|
+
.fi-result { margin: var(--sp-sm) 0 0; max-width: var(--measure); font-size: 14px; line-height: 1.5; color: var(--ink-soft); }
|
|
80
|
+
.fi-result a { display: inline-block; min-height: 24px; padding: 3px 0; line-height: 18px; box-sizing: border-box; color: inherit; }
|
|
81
|
+
|
|
77
82
|
.fi-actions { display: flex; flex-wrap: wrap; gap: 10px 12px; align-items: center; margin-top: var(--sp-md); }
|
|
78
83
|
.fi-draft { font-size: 12.5px; color: var(--ink-faint); }
|
|
79
84
|
|
|
@@ -47,6 +47,60 @@
|
|
|
47
47
|
<div id="fi-queue"><p class="fi-note">Loading…</p></div>
|
|
48
48
|
</section>
|
|
49
49
|
|
|
50
|
+
<!-- Quick filing (task 1003069 / R08, goal 1000071 "Idea routing") —
|
|
51
|
+
the hall's first bug/idea report, separate from the Full Idea
|
|
52
|
+
composer below: title + kind + optional body + a goal picker fed
|
|
53
|
+
by the R07 suggester. A routed capture must never carry
|
|
54
|
+
grade='full' (routing.js NEVER_ROUTE_GRADE), so this cannot share
|
|
55
|
+
a submit with the form below it. Hidden while developing a spark
|
|
56
|
+
(?develop=<id>) — that surface always turns someone else's
|
|
57
|
+
existing thought into a Full Idea, never a fresh quick report. -->
|
|
58
|
+
<section class="scroll" id="fq-compose-scroll" aria-labelledby="fq-compose-h">
|
|
59
|
+
<h2 id="fq-compose-h" class="scroll__h">File a bug or idea</h2>
|
|
60
|
+
<p class="scroll__lede">
|
|
61
|
+
A quick report — title, what's going on, and where it belongs. Pick a goal
|
|
62
|
+
and it lands there as a task the moment you file; leave it on "no goal" and
|
|
63
|
+
it waits in the inbox for triage.
|
|
64
|
+
</p>
|
|
65
|
+
|
|
66
|
+
<form id="fq-form" novalidate>
|
|
67
|
+
<label class="fi-label" for="fq-title">Title</label>
|
|
68
|
+
<input type="text" id="fq-title" class="fi-input" maxlength="200" autocomplete="off"
|
|
69
|
+
placeholder="what's wrong, or what you'd like to see">
|
|
70
|
+
|
|
71
|
+
<label class="fi-label" for="fq-kind">Kind</label>
|
|
72
|
+
<select id="fq-kind" class="fi-input">
|
|
73
|
+
<option value="bug" selected>Bug</option>
|
|
74
|
+
<option value="feature">Feature</option>
|
|
75
|
+
<option value="infra">Infra</option>
|
|
76
|
+
<option value="refactor">Refactor</option>
|
|
77
|
+
<option value="spike">Spike</option>
|
|
78
|
+
<option value="learning-capture">Learning capture</option>
|
|
79
|
+
<option value="cleanup">Cleanup</option>
|
|
80
|
+
<option value="decision">Decision</option>
|
|
81
|
+
<option value="blocker-resolution">Blocker resolution</option>
|
|
82
|
+
<option value="unclassified">Unclassified</option>
|
|
83
|
+
</select>
|
|
84
|
+
|
|
85
|
+
<label class="fi-label" for="fq-body">Details <span class="fi-hint">(optional)</span></label>
|
|
86
|
+
<textarea id="fq-body" class="fi-text" rows="4"
|
|
87
|
+
placeholder="repro steps, context, anything that helps…"></textarea>
|
|
88
|
+
|
|
89
|
+
<label class="fi-label" for="fq-goal">
|
|
90
|
+
Goal
|
|
91
|
+
<span class="fi-hint">Suggested as you type; leave it on "no goal" and it waits for triage.</span>
|
|
92
|
+
</label>
|
|
93
|
+
<select id="fq-goal" class="fi-input"></select>
|
|
94
|
+
|
|
95
|
+
<p class="fi-err" id="fq-err" role="alert"></p>
|
|
96
|
+
<p class="fi-result" id="fq-result" role="status" aria-live="polite"></p>
|
|
97
|
+
|
|
98
|
+
<div class="fi-actions">
|
|
99
|
+
<button type="submit" class="btn" id="fq-submit">File it</button>
|
|
100
|
+
</div>
|
|
101
|
+
</form>
|
|
102
|
+
</section>
|
|
103
|
+
|
|
50
104
|
<!-- The composer. The idea detail view lives on its own page (R153). -->
|
|
51
105
|
<section class="scroll" id="fi-compose-scroll" aria-labelledby="fi-compose-h">
|
|
52
106
|
<h2 id="fi-compose-h" class="scroll__h">Write a Full Idea</h2>
|
|
@@ -121,6 +175,9 @@
|
|
|
121
175
|
<script src="/builders/shell.js?v=2026-07-02-shell1"></script>
|
|
122
176
|
<script src="/builders/palette.js" defer></script>
|
|
123
177
|
<script src="/builders/dom-utils.js?v=2026-06-23-contrib"></script>
|
|
178
|
+
<!-- task 1003069 (R08) — the quick-filing form's routed result links a task
|
|
179
|
+
through window.OTBTaskDetail.href(); guarded if this ever loads without it. -->
|
|
180
|
+
<script src="/builders/task-detail.js?v=2026-07-03-taskdetail2" defer></script>
|
|
124
181
|
<script src="/builders/ideas.js?v=2026-08-22-r154"></script>
|
|
125
182
|
</body>
|
|
126
183
|
</html>
|
|
@@ -75,6 +75,20 @@
|
|
|
75
75
|
let draftTimer = null;
|
|
76
76
|
let filing = false;
|
|
77
77
|
|
|
78
|
+
// QUICK FILING (task 1003069 / R08, goal 1000071 "Idea routing"). A second,
|
|
79
|
+
// independent capture path on this page: title + kind + optional body + a
|
|
80
|
+
// goal picker fed by the R07 suggester (GET /goals/suggestions). It lives
|
|
81
|
+
// beside the Full Idea form above rather than inside it — a routed capture
|
|
82
|
+
// must never carry grade='full' (routing.js NEVER_ROUTE_GRADE), so the two
|
|
83
|
+
// cannot share one submit. Quick-mode only: hidden while developing a spark.
|
|
84
|
+
const QUICK_SUGGEST_DEBOUNCE_MS = 300;
|
|
85
|
+
let buildingVersionIds = []; // fetched once — the suggester is scoped per version
|
|
86
|
+
let allOpenGoals = []; // the picker's "browse everything" half
|
|
87
|
+
let allGoalsById = new Map(); // for naming the goal a routed capture landed in
|
|
88
|
+
let goalTouchedByUser = false; // once the builder picks, stop clobbering it with fresh suggestions
|
|
89
|
+
let quickSuggestTimer = null;
|
|
90
|
+
let quickFiling = false;
|
|
91
|
+
|
|
78
92
|
// ---------------------------------------------------------------------------
|
|
79
93
|
// draft persistence
|
|
80
94
|
// ---------------------------------------------------------------------------
|
|
@@ -347,6 +361,155 @@
|
|
|
347
361
|
toast('Draft discarded.');
|
|
348
362
|
}
|
|
349
363
|
|
|
364
|
+
// ---------------------------------------------------------------------------
|
|
365
|
+
// quick filing — kind + goal picker (task 1003069 / R08)
|
|
366
|
+
// ---------------------------------------------------------------------------
|
|
367
|
+
|
|
368
|
+
const fqTitleEl = () => $('#fq-title');
|
|
369
|
+
const fqKindEl = () => $('#fq-kind');
|
|
370
|
+
const fqBodyEl = () => $('#fq-body');
|
|
371
|
+
const fqGoalEl = () => $('#fq-goal');
|
|
372
|
+
|
|
373
|
+
// The picker's options: "no goal" first (the default — a filer never has to
|
|
374
|
+
// know the goal map, C5), then whatever the suggester found, then every open
|
|
375
|
+
// goal it didn't. Suggested ids are excluded from the second list rather than
|
|
376
|
+
// shown twice.
|
|
377
|
+
function renderGoalOptions(suggested) {
|
|
378
|
+
const sel = fqGoalEl();
|
|
379
|
+
const suggestedIds = new Set((suggested || []).map((g) => String(g.id)));
|
|
380
|
+
const rest = allOpenGoals.filter((g) => !suggestedIds.has(String(g.id)));
|
|
381
|
+
const optionHtml = (g, why) => `<option value="${escapeHtml(String(g.id))}">${escapeHtml(g.title || `goal ${g.id}`)}${why ? ` — matched: ${escapeHtml(why)}` : ''}</option>`;
|
|
382
|
+
let html = `<option value="" selected>No goal (homeless)</option>`;
|
|
383
|
+
if (suggested && suggested.length) {
|
|
384
|
+
html += `<optgroup label="Suggested">${suggested.map((g) => optionHtml(g, (g.matched_terms || []).join(', '))).join('')}</optgroup>`;
|
|
385
|
+
}
|
|
386
|
+
html += `<optgroup label="All open goals">${rest.map((g) => optionHtml(g)).join('')}</optgroup>`;
|
|
387
|
+
sel.innerHTML = html;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// The suggester is ranked per version (GET /goals/suggestions requires one),
|
|
391
|
+
// so a filer whose instance has more than one version building gets both
|
|
392
|
+
// consulted and merged by score. An outage — or no building version at all —
|
|
393
|
+
// degrades to "no suggestions"; the homeless option and the full goal list
|
|
394
|
+
// are still there either way (goal-suggest.js's own fail-open contract).
|
|
395
|
+
async function refreshGoalSuggestions() {
|
|
396
|
+
if (goalTouchedByUser) return; // never yank a manual pick out from under someone
|
|
397
|
+
const title = fqTitleEl().value.trim();
|
|
398
|
+
if (!title || !buildingVersionIds.length) { renderGoalOptions([]); return; }
|
|
399
|
+
const kind = fqKindEl().value;
|
|
400
|
+
try {
|
|
401
|
+
const results = await Promise.all(buildingVersionIds.map((versionId) => api.goals.getGoalsSuggestions({
|
|
402
|
+
query: { version: versionId, title, kind, limit: 5 },
|
|
403
|
+
})));
|
|
404
|
+
const byId = new Map();
|
|
405
|
+
for (const r of results) {
|
|
406
|
+
if (!r.ok || !r.data || !Array.isArray(r.data.suggestions)) continue;
|
|
407
|
+
for (const g of r.data.suggestions) {
|
|
408
|
+
const prev = byId.get(String(g.id));
|
|
409
|
+
if (!prev || g.score > prev.score) byId.set(String(g.id), g);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
const merged = Array.from(byId.values()).sort((a, b) => b.score - a.score).slice(0, 5);
|
|
413
|
+
renderGoalOptions(merged);
|
|
414
|
+
} catch (_) {
|
|
415
|
+
renderGoalOptions([]);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function onQuickTitleEdit() {
|
|
420
|
+
clearTimeout(quickSuggestTimer);
|
|
421
|
+
quickSuggestTimer = setTimeout(refreshGoalSuggestions, QUICK_SUGGEST_DEBOUNCE_MS);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function showQuickErr(msg) {
|
|
425
|
+
$('#fq-err').textContent = msg || '';
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// The server names why a routed capture was refused (task 1003066's landing
|
|
429
|
+
// gate) or names the refusal code when it doesn't — read it, never re-derive
|
|
430
|
+
// the landing matrix here (routing.js stays the one place that decides).
|
|
431
|
+
function explainQuickFiling(status, data) {
|
|
432
|
+
const err = data && data.error;
|
|
433
|
+
const code = err && (typeof err === 'object' ? err.code : err);
|
|
434
|
+
const details = (err && typeof err === 'object' && err.details) || {};
|
|
435
|
+
if (details.reason) return `Not filed — ${details.reason}`;
|
|
436
|
+
if (code) return `Not filed — ${code}.`;
|
|
437
|
+
if (status === 401 || status === 403) return 'Not filed — your session may have expired. Reload and try again.';
|
|
438
|
+
return `Not filed (${status}). Nothing was saved — try again.`;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
async function fileQuick(ev) {
|
|
442
|
+
ev.preventDefault();
|
|
443
|
+
if (quickFiling) return;
|
|
444
|
+
const title = fqTitleEl().value.trim();
|
|
445
|
+
showQuickErr('');
|
|
446
|
+
$('#fq-result').innerHTML = '';
|
|
447
|
+
if (!title) { showQuickErr('Not filed — a title is required.'); return; }
|
|
448
|
+
quickFiling = true;
|
|
449
|
+
$('#fq-submit').disabled = true;
|
|
450
|
+
|
|
451
|
+
const goalId = fqGoalEl().value;
|
|
452
|
+
const body = { title, body_md: fqBodyEl().value, kind: fqKindEl().value };
|
|
453
|
+
if (goalId) body.goal_id = Number(goalId);
|
|
454
|
+
|
|
455
|
+
const r = await api.inbox.postInbox({ body });
|
|
456
|
+
quickFiling = false;
|
|
457
|
+
$('#fq-submit').disabled = false;
|
|
458
|
+
|
|
459
|
+
if (!r.ok) {
|
|
460
|
+
showQuickErr(explainQuickFiling(r.status, r.data));
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// The routing verdict travels on idea.routing; a routed capture also
|
|
465
|
+
// carries the task it became at the top level (routed_task) — see
|
|
466
|
+
// routes/inbox.js. Not routed (no goal picked, or the matrix declined
|
|
467
|
+
// one) is exactly today's inbox behaviour.
|
|
468
|
+
const routedTask = r.data && r.data.routed_task;
|
|
469
|
+
const routing = r.data && r.data.idea && r.data.idea.routing;
|
|
470
|
+
if (routedTask && routedTask.id && routing && routing.routed) {
|
|
471
|
+
const href = window.OTBTaskDetail && typeof window.OTBTaskDetail.href === 'function'
|
|
472
|
+
? window.OTBTaskDetail.href(routedTask.id)
|
|
473
|
+
: `/task/${encodeURIComponent(routedTask.id)}`;
|
|
474
|
+
const goal = allGoalsById.get(String(routing.goal_id));
|
|
475
|
+
const goalName = goal ? goal.title : `goal ${routing.goal_id}`;
|
|
476
|
+
$('#fq-result').innerHTML = `Filed as <a href="${href}">task ${escapeHtml(String(routedTask.id))}</a> under ${escapeHtml(goalName)}.`;
|
|
477
|
+
} else {
|
|
478
|
+
$('#fq-result').textContent = 'Filed to the inbox.';
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
fqTitleEl().value = '';
|
|
482
|
+
fqBodyEl().value = '';
|
|
483
|
+
fqKindEl().value = 'bug';
|
|
484
|
+
goalTouchedByUser = false;
|
|
485
|
+
renderGoalOptions([]);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
async function bootQuickComposer() {
|
|
489
|
+
fqKindEl().value = 'bug';
|
|
490
|
+
renderGoalOptions([]);
|
|
491
|
+
fqTitleEl().addEventListener('input', onQuickTitleEdit);
|
|
492
|
+
fqKindEl().addEventListener('change', onQuickTitleEdit);
|
|
493
|
+
fqGoalEl().addEventListener('change', () => { goalTouchedByUser = true; });
|
|
494
|
+
$('#fq-form').addEventListener('submit', fileQuick);
|
|
495
|
+
|
|
496
|
+
try {
|
|
497
|
+
const [versionsRes, goalsRes] = await Promise.all([
|
|
498
|
+
api.versions.getVersions({}),
|
|
499
|
+
api.goals.getGoals({ query: { status: 'open' } }),
|
|
500
|
+
]);
|
|
501
|
+
buildingVersionIds = (versionsRes.ok && versionsRes.data && Array.isArray(versionsRes.data.versions))
|
|
502
|
+
? versionsRes.data.versions.filter((v) => v.status === 'building').map((v) => v.id)
|
|
503
|
+
: [];
|
|
504
|
+
allOpenGoals = (goalsRes.ok && goalsRes.data && Array.isArray(goalsRes.data.goals)) ? goalsRes.data.goals : [];
|
|
505
|
+
allGoalsById = new Map(allOpenGoals.map((g) => [String(g.id), g]));
|
|
506
|
+
renderGoalOptions([]);
|
|
507
|
+
} catch (_) {
|
|
508
|
+
// The picker degrades to "homeless only" — filing itself never depends
|
|
509
|
+
// on this read succeeding.
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
350
513
|
// ---------------------------------------------------------------------------
|
|
351
514
|
// boot
|
|
352
515
|
// ---------------------------------------------------------------------------
|
|
@@ -433,6 +596,16 @@
|
|
|
433
596
|
// A surface that does not depend on the failure must not be hostage to it.
|
|
434
597
|
loadQueue();
|
|
435
598
|
|
|
599
|
+
// Quick filing (R08) is a THIRD independent surface on this page, offered
|
|
600
|
+
// only outside develop mode — developing a spark always produces a Full
|
|
601
|
+
// Idea, never a fresh quick report (see the section's own header comment).
|
|
602
|
+
if (DEVELOP_ID) {
|
|
603
|
+
const scroll = $('#fq-compose-scroll');
|
|
604
|
+
if (scroll) scroll.hidden = true;
|
|
605
|
+
} else {
|
|
606
|
+
bootQuickComposer();
|
|
607
|
+
}
|
|
608
|
+
|
|
436
609
|
const r = await api.inbox.getInboxTemplate({});
|
|
437
610
|
if (!r.ok || !r.data || !r.data.template) {
|
|
438
611
|
$('#fi-sub').textContent = 'Could not load the idea template. Reload, or file from the terminal with capture.js.';
|
|
@@ -66,6 +66,24 @@
|
|
|
66
66
|
return data;
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
// task 1003063 (R10): mutating call for the move-to-goal affordance. Same
|
|
70
|
+
// posture as postJSON — resolves to the parsed 2xx body, throws an Error
|
|
71
|
+
// carrying { status, data } on a non-2xx so the caller can read the server's
|
|
72
|
+
// own refusal sentence (ADR 0016 enforces; this only surfaces it).
|
|
73
|
+
async function patchJSON(path, body) {
|
|
74
|
+
const res = await api.request('PATCH', path, { body: body || {} });
|
|
75
|
+
if (res.status === 401) { toAuth(); throw new Error('redirecting to auth'); }
|
|
76
|
+
let data = null;
|
|
77
|
+
try { data = res.data; } catch (_) { /* empty / non-JSON body */ }
|
|
78
|
+
if (!res.ok) {
|
|
79
|
+
const err = new Error(`${path} → ${res.status}`);
|
|
80
|
+
err.status = res.status;
|
|
81
|
+
err.data = data || {};
|
|
82
|
+
throw err;
|
|
83
|
+
}
|
|
84
|
+
return data;
|
|
85
|
+
}
|
|
86
|
+
|
|
69
87
|
// Map the server's typed lifecycle-error to a builder-readable line. Codes are
|
|
70
88
|
// exactly those POST /tasks/:id/ship + /confirm can return. The server is the
|
|
71
89
|
// authority (ADR 0016); the UI only surfaces the refusal.
|
|
@@ -430,11 +448,17 @@
|
|
|
430
448
|
}
|
|
431
449
|
|
|
432
450
|
function renderTask(t, lastClaim, viewer, goal, extras) {
|
|
433
|
-
const { grade = null, sessionLogs = null, claims = null } = extras || {};
|
|
451
|
+
const { grade = null, sessionLogs = null, claims = null, goalsList = null } = extras || {};
|
|
434
452
|
document.title = `#${t.id} — ${t.title || 'Task'} — ${document.title.split(' — ').pop()}`;
|
|
435
453
|
const sub = $('#task-sub');
|
|
436
454
|
if (sub) sub.textContent = `Task ${t.id}`;
|
|
437
455
|
|
|
456
|
+
// Metic+ is the floor for both the lifecycle supervisor controls below and
|
|
457
|
+
// the move-to-goal affordance (task 1003063 / R10) — hoisted so the two
|
|
458
|
+
// gates read the same rank once, rather than risking drift between copies.
|
|
459
|
+
const rank = (viewer && viewer.rank) ? String(viewer.rank).toLowerCase() : '';
|
|
460
|
+
const isSupervisor = rank === 'metic' || rank === 'archon';
|
|
461
|
+
|
|
438
462
|
const parts = [];
|
|
439
463
|
parts.push(`<p class="rec-kind">Task ${statusPill(t.status)}</p>`);
|
|
440
464
|
parts.push(`<h1 class="rec-title" id="task-h"><span class="rec-title__id">${escapeHtml(String(t.id))}</span>${escapeHtml(t.title || 'Untitled task')}</h1>`);
|
|
@@ -473,6 +497,28 @@
|
|
|
473
497
|
parts.push(`<p class="rec-goal">Goal <a href="/goals#/goal/${encodeURIComponent(t.goal_id)}">${escapeHtml(String(t.goal_id))}${goalName}</a>${catChip}</p>`);
|
|
474
498
|
}
|
|
475
499
|
|
|
500
|
+
// task 1003063 (R10): the reassignment affordance — so a wrong routing pick
|
|
501
|
+
// is one PATCH away, never a re-file. Metic+ only (the server's own floor on
|
|
502
|
+
// PATCH /tasks/:id, ADR 0090); goalsList is fetched by load() scoped to this
|
|
503
|
+
// task's own version, because the server refuses a cross-version move
|
|
504
|
+
// (goal_version_mismatch, 409) and there is no point offering one.
|
|
505
|
+
if (isSupervisor && t.version_id) {
|
|
506
|
+
const openGoals = (Array.isArray(goalsList) ? goalsList : []).filter((g) => g.status !== 'archived');
|
|
507
|
+
const goalOpts = openGoals.length
|
|
508
|
+
? openGoals.map((g) => `<option value="${escapeHtml(String(g.id))}"${String(g.id) === String(t.goal_id) ? ' selected' : ''}>${escapeHtml(String(g.id))} — ${escapeHtml(g.title || '')}</option>`).join('')
|
|
509
|
+
: '<option value="">No open goals in this version</option>';
|
|
510
|
+
parts.push(`
|
|
511
|
+
<div class="td-form" id="move-goal-form">
|
|
512
|
+
<label class="td-field td-field--wide"><span>Move to goal</span>
|
|
513
|
+
<select id="move-goal-select">${goalOpts}</select>
|
|
514
|
+
</label>
|
|
515
|
+
<div class="td-actions">
|
|
516
|
+
<button type="button" class="td-btn" id="move-goal-btn">Move</button>
|
|
517
|
+
</div>
|
|
518
|
+
</div>
|
|
519
|
+
`);
|
|
520
|
+
}
|
|
521
|
+
|
|
476
522
|
const creditVal = window.OTBBranding?.taskCreditValue?.(t) ?? t.credits_reward;
|
|
477
523
|
parts.push(figureBand([
|
|
478
524
|
[window.OTBBranding?.taskCreditLabel?.(t) ?? curLabel(), creditVal],
|
|
@@ -550,8 +596,6 @@
|
|
|
550
596
|
// toast. `confirmed → shipped` is the any-builder-supervisor / own-claim ship;
|
|
551
597
|
// `completed → confirmed` is the Archon-only grader-bypass confirm (others see
|
|
552
598
|
// a read-only note — the grader/Archon still has to act).
|
|
553
|
-
const rank = (viewer && viewer.rank) ? String(viewer.rank).toLowerCase() : '';
|
|
554
|
-
const isSupervisor = rank === 'metic' || rank === 'archon';
|
|
555
599
|
const ownsLastClaim = !!(viewer && lastClaim && lastClaim.github_login &&
|
|
556
600
|
lastClaim.github_login === viewer.github_login);
|
|
557
601
|
let lifecycle = '';
|
|
@@ -610,6 +654,33 @@
|
|
|
610
654
|
runLifecycle(confirmBtn, `${API}/tasks/${encodeURIComponent(t.id)}/confirm`, body, `#${t.id} confirmed.`);
|
|
611
655
|
});
|
|
612
656
|
}
|
|
657
|
+
|
|
658
|
+
// task 1003063 (R10): PATCH goal_id, then a full reload so the goal line, the
|
|
659
|
+
// picker's preselected option and everything else on the record re-render
|
|
660
|
+
// against the server's own state — no local patchwork of the DOM.
|
|
661
|
+
const moveGoalBtn = $('#move-goal-btn');
|
|
662
|
+
if (moveGoalBtn) {
|
|
663
|
+
moveGoalBtn.addEventListener('click', async () => {
|
|
664
|
+
const sel = $('#move-goal-select');
|
|
665
|
+
const goalId = sel ? Number(sel.value) : NaN;
|
|
666
|
+
if (!Number.isInteger(goalId) || goalId <= 0) { toast('Choose a goal first.'); return; }
|
|
667
|
+
const orig = moveGoalBtn.textContent;
|
|
668
|
+
moveGoalBtn.disabled = true;
|
|
669
|
+
moveGoalBtn.textContent = '…';
|
|
670
|
+
try {
|
|
671
|
+
await patchJSON(`${API}/tasks/${encodeURIComponent(t.id)}`, { goal_id: goalId });
|
|
672
|
+
toast(`#${t.id} moved to goal ${goalId}.`);
|
|
673
|
+
await load();
|
|
674
|
+
} catch (err) {
|
|
675
|
+
if (err && err.message === 'redirecting to auth') return;
|
|
676
|
+
// The server's own sentence, verbatim (a rank_forbidden 403 or a
|
|
677
|
+
// goal_version_mismatch 409 both say exactly what went wrong).
|
|
678
|
+
toast(errMessage(err && err.data) || `Could not move that task (${(err && err.status) || '?'}). Try again.`);
|
|
679
|
+
moveGoalBtn.disabled = false;
|
|
680
|
+
moveGoalBtn.textContent = orig;
|
|
681
|
+
}
|
|
682
|
+
});
|
|
683
|
+
}
|
|
613
684
|
}
|
|
614
685
|
|
|
615
686
|
// The two honest failures. Both keep the <h1> — the empty and error renders are
|
|
@@ -664,6 +735,18 @@
|
|
|
664
735
|
goal = (g && g.goal) ? g.goal : null;
|
|
665
736
|
} catch (_) { /* cosmetic — the goal link still resolves without it */ }
|
|
666
737
|
}
|
|
738
|
+
// task 1003063 (R10): the move-to-goal picker's options, fetched only for
|
|
739
|
+
// the rank that can act on it and scoped to this task's own version (the
|
|
740
|
+
// server refuses a cross-version move). Best-effort: a failed read just
|
|
741
|
+
// means an empty picker, never a blocked page.
|
|
742
|
+
let goalsList = null;
|
|
743
|
+
const viewerRank = (viewer && viewer.rank) ? String(viewer.rank).toLowerCase() : '';
|
|
744
|
+
if ((viewerRank === 'metic' || viewerRank === 'archon') && d.task.version_id) {
|
|
745
|
+
try {
|
|
746
|
+
const gl = await getJSON(`${API}/goals?version=${encodeURIComponent(d.task.version_id)}`, { softAuth: true });
|
|
747
|
+
goalsList = Array.isArray(gl && gl.goals) ? gl.goals : [];
|
|
748
|
+
} catch (_) { /* cosmetic — the picker just has nothing to offer */ }
|
|
749
|
+
}
|
|
667
750
|
// The repo bases a description's relative paths resolve onto (the primer's
|
|
668
751
|
// and drachmae's pattern). Best-effort: without them a path passes through.
|
|
669
752
|
try {
|
|
@@ -675,6 +758,7 @@
|
|
|
675
758
|
grade: d.grade || null,
|
|
676
759
|
sessionLogs: Array.isArray(d.session_logs) ? d.session_logs : null,
|
|
677
760
|
claims: Array.isArray(d.claims) ? d.claims : null,
|
|
761
|
+
goalsList,
|
|
678
762
|
});
|
|
679
763
|
} catch (err) {
|
|
680
764
|
if (err && err.code === AUTH_NEEDED) { toAuth(); return; }
|
package/modules/ideas/inbox.js
CHANGED
|
@@ -240,6 +240,13 @@ async function captureIdea({
|
|
|
240
240
|
// (a hall filing surface, a Discord classifier) can, and when it does the
|
|
241
241
|
// goal's scope wall is applied to it verbatim. See ./routing.js.
|
|
242
242
|
touches = null,
|
|
243
|
+
// task 1003068 (BV1.R12): a HINT-ONLY goal guess — NEVER fed to decideLanding,
|
|
244
|
+
// only recorded as the advisory suggested_goal_id column. `goalId` is a
|
|
245
|
+
// caller's routing DECISION; this is a machine guess with no such authority
|
|
246
|
+
// (the Discord #ideas classifier has no human behind it to have decided
|
|
247
|
+
// anything). Ignored when `goalId` is already set, so every existing caller
|
|
248
|
+
// is untouched.
|
|
249
|
+
suggestedGoalId = null,
|
|
243
250
|
}, deps = {}) {
|
|
244
251
|
if (!title || typeof title !== 'string') {
|
|
245
252
|
throw { code: 'TITLE_REQUIRED' };
|
|
@@ -277,7 +284,7 @@ async function captureIdea({
|
|
|
277
284
|
$7)
|
|
278
285
|
RETURNING ${IDEA_COLS}`,
|
|
279
286
|
[title, bodyMd, resolvedKind, suggestedVersion, suggestedPriority, capturedBy,
|
|
280
|
-
goalId ?? null]
|
|
287
|
+
goalId ?? suggestedGoalId ?? null]
|
|
281
288
|
);
|
|
282
289
|
// A brand-new idea has no ideas_grades row, so it is quick by definition —
|
|
283
290
|
// no join needed to know that. Grading it (R143's Full Idea capture) is a
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.498",
|
|
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.498",
|
|
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.498",
|
|
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",
|