@bongos/core 1.19.287 → 1.19.289
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 +45 -25
- package/.claude/skills/goal-review/SKILL.md +15 -2
- 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 -2
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/adr/0183-criteria-close-themselves.md +58 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +35 -22
- package/docs/api-reference.md +3 -2
- package/modules/lifecycle/db.js +1 -1
- package/modules/lifecycle/done-when.js +145 -0
- package/modules/lifecycle/goal-suggest.js +171 -0
- package/modules/lifecycle/publish-reconciler.js +51 -6
- package/modules/lifecycle/routes/goals.js +9 -19
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/bongos/route-rank-check.js +17 -2
- package/src/module-api.js +5 -1
- package/tests/auto_satisfy_criteria.mjs +212 -0
- package/tests/goal_suggest.mjs +195 -0
|
@@ -32,6 +32,14 @@ const { pool } = require('../../src/module-api');
|
|
|
32
32
|
// (db.listCriteriaForGoal, task 1002164) both call it; listPendingReviewCriteria
|
|
33
33
|
// expresses the same rule as a SQL HAVING clause. Counts are coerced because
|
|
34
34
|
// node-postgres renders count() as a string unless cast.
|
|
35
|
+
// DELIBERATELY WIDER THAN AUTO-CLOSE (task 1002069). This flag means "a human
|
|
36
|
+
// should LOOK at this", and task 1002651 chose on purpose that a criterion whose
|
|
37
|
+
// linked tasks were all ABANDONED surfaces here — its work was cut, so someone
|
|
38
|
+
// must decide whether the criterion still means anything. Auto-close is the
|
|
39
|
+
// strictly NARROWER question ("may this close itself, unattended?") and carries
|
|
40
|
+
// an extra >=1-shipped guard of its own; see autoSatisfyShippedCriteria. So
|
|
41
|
+
// everything that auto-closes was flagged here, but not everything flagged here
|
|
42
|
+
// auto-closes — the all-abandoned case stays for a person. Do not "align" them.
|
|
35
43
|
function isPendingReview({ satisfied, linked, unshipped }) {
|
|
36
44
|
return !satisfied && Number(linked) > 0 && Number(unshipped) === 0;
|
|
37
45
|
}
|
|
@@ -357,6 +365,140 @@ async function unmarkCriterion(criterionId) {
|
|
|
357
365
|
return rows[0] ?? null;
|
|
358
366
|
}
|
|
359
367
|
|
|
368
|
+
// -------------------------------------------------------------------------
|
|
369
|
+
// Auto-satisfy (task 1002069 / ADR 0183)
|
|
370
|
+
// -------------------------------------------------------------------------
|
|
371
|
+
|
|
372
|
+
// autoSatisfyShippedCriteria — flip every criterion whose gating set is fully
|
|
373
|
+
// DELIVERED to satisfied=true, then cascade any goal whose criteria are now all
|
|
374
|
+
// satisfied to `achieved`. This retires the manual /goal-review confirm step as
|
|
375
|
+
// the ONLY way a criterion closes (owner request 2026-07-05): the review queue
|
|
376
|
+
// becomes a place to look, not a gate to pass.
|
|
377
|
+
//
|
|
378
|
+
// `exec` is a pg client OR the pool — the caller decides. That is the whole
|
|
379
|
+
// design: shipTask passes its OPEN TRANSACTION CLIENT so the criterion flip and
|
|
380
|
+
// the goal cascade commit atomically with the task's own status change (a
|
|
381
|
+
// half-applied close would leave a goal achieved on a task that rolled back),
|
|
382
|
+
// while the publish-reconciler sweep passes the pool. Never reach for the module
|
|
383
|
+
// pool in here.
|
|
384
|
+
//
|
|
385
|
+
// TWO CALL SHAPES:
|
|
386
|
+
// { taskId } — SCOPED: only criteria the just-shipped task gates. The hot
|
|
387
|
+
// path, inside the ship txn.
|
|
388
|
+
// {} — UNSCOPED: every unsatisfied criterion. The sweep, which both
|
|
389
|
+
// drains the backlog of criteria that completed before this
|
|
390
|
+
// feature existed and self-heals anything the hot path missed
|
|
391
|
+
// (a ship that predates the hook, a manual DB status change).
|
|
392
|
+
//
|
|
393
|
+
// IDEMPOTENT by construction — it only ever reads `satisfied = false` rows, so a
|
|
394
|
+
// second run over the same data is a no-op, which is what makes the sweep safe
|
|
395
|
+
// to run on every reconciler tick.
|
|
396
|
+
//
|
|
397
|
+
// THE DELIVERY GUARD. Two conditions, and BOTH are required:
|
|
398
|
+
// - nothing still pending: no linked task outside {shipped, abandoned}
|
|
399
|
+
// - something delivered: at least one linked task actually SHIPPED
|
|
400
|
+
// The second is not redundant. Since task 1002651 made `abandoned` terminal, a
|
|
401
|
+
// criterion whose linked tasks were ALL abandoned satisfies the first condition
|
|
402
|
+
// vacuously — every task is accounted for, none is pending — and would auto-close
|
|
403
|
+
// on ZERO delivered work, cascading its goal to `achieved`. Under the old manual
|
|
404
|
+
// gate that was merely a bad row in a review queue a human would reject; automatic,
|
|
405
|
+
// it silently declares goals complete. See isPendingReview, which carries the
|
|
406
|
+
// same guard so the queue and the automation cannot disagree.
|
|
407
|
+
//
|
|
408
|
+
// Returns { criteria: [...], goals: [...] } — the rows actually changed, so a
|
|
409
|
+
// caller can log or broadcast exactly what closed (empty arrays on a no-op).
|
|
410
|
+
async function autoSatisfyShippedCriteria(exec, { taskId = null } = {}) {
|
|
411
|
+
const scopedTaskId = taskId == null ? null : Number(taskId);
|
|
412
|
+
if (scopedTaskId != null && !Number.isFinite(scopedTaskId)) {
|
|
413
|
+
throw new Error('autoSatisfyShippedCriteria: taskId must be numeric');
|
|
414
|
+
}
|
|
415
|
+
const { rows: criteria } = await exec.query(
|
|
416
|
+
`WITH candidate AS (
|
|
417
|
+
SELECT c.id,
|
|
418
|
+
-- Attribute the close to a task that actually shipped. In scoped
|
|
419
|
+
-- mode that is the shipping task; unscoped, the highest shipped id
|
|
420
|
+
-- (deterministic, and the most recent contributor).
|
|
421
|
+
max(tc.task_id) FILTER (WHERE t.status = 'shipped') AS by_task
|
|
422
|
+
FROM done_when_criteria c
|
|
423
|
+
JOIN task_criteria tc ON tc.criterion_id = c.id
|
|
424
|
+
JOIN tasks t ON t.id = tc.task_id
|
|
425
|
+
WHERE c.satisfied = false
|
|
426
|
+
AND ($1::bigint IS NULL OR c.id IN (
|
|
427
|
+
SELECT criterion_id FROM task_criteria WHERE task_id = $1::bigint))
|
|
428
|
+
GROUP BY c.id
|
|
429
|
+
HAVING count(*) FILTER (WHERE t.status NOT IN ('shipped', 'abandoned')) = 0
|
|
430
|
+
AND count(*) FILTER (WHERE t.status = 'shipped') >= 1
|
|
431
|
+
)
|
|
432
|
+
UPDATE done_when_criteria c
|
|
433
|
+
SET satisfied = true,
|
|
434
|
+
satisfied_by_task_id = COALESCE($1::bigint, candidate.by_task),
|
|
435
|
+
checked_at = now()
|
|
436
|
+
FROM candidate
|
|
437
|
+
WHERE c.id = candidate.id
|
|
438
|
+
RETURNING c.id, c.version_id, c.criterion_id, c.criterion_md, c.goal_id,
|
|
439
|
+
c.satisfied_by_task_id`,
|
|
440
|
+
[scopedTaskId]
|
|
441
|
+
);
|
|
442
|
+
if (criteria.length === 0) return { criteria: [], goals: [] };
|
|
443
|
+
|
|
444
|
+
// Cascade. Deliberately re-derived from the table rather than from `criteria`:
|
|
445
|
+
// a goal completes when ALL of its criteria are satisfied, and the ones that
|
|
446
|
+
// closed in THIS call may not be all of them. Mirrors db.achieveGoalIfComplete
|
|
447
|
+
// exactly — open goals only, >=1 criterion, zero unsatisfied — but on `exec`,
|
|
448
|
+
// so it sees the flips above inside the same transaction.
|
|
449
|
+
const goalIds = [...new Set(criteria.map((c) => c.goal_id).filter((g) => g != null))].map(Number);
|
|
450
|
+
if (goalIds.length === 0) return { criteria, goals: [] };
|
|
451
|
+
const { rows: goals } = await exec.query(
|
|
452
|
+
`UPDATE goals g
|
|
453
|
+
SET status = 'achieved', updated_at = now()
|
|
454
|
+
WHERE g.id = ANY($1::bigint[])
|
|
455
|
+
AND g.status = 'open'
|
|
456
|
+
AND EXISTS (SELECT 1 FROM done_when_criteria d WHERE d.goal_id = g.id)
|
|
457
|
+
AND NOT EXISTS (SELECT 1 FROM done_when_criteria d WHERE d.goal_id = g.id AND NOT d.satisfied)
|
|
458
|
+
RETURNING g.id, g.title, g.version_id, g.status`,
|
|
459
|
+
[goalIds]
|
|
460
|
+
);
|
|
461
|
+
return { criteria, goals };
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// describeAutoClose — the one-line summary of what a close actually closed, or
|
|
465
|
+
// null on a no-op. Pure + here rather than in db.js because db.js is over the
|
|
466
|
+
// fitness oversize threshold: growth there has to be paid for, and formatting is
|
|
467
|
+
// not state-machine work (task 1002069).
|
|
468
|
+
// closeCompletedWorkForShip — the whole ship-time hook behind ONE call, so the
|
|
469
|
+
// oversized db.js pays ~4 lines for it instead of ~15 (the fitness excess-lines
|
|
470
|
+
// ratchet is why that matters). Takes the ship's OPEN TXN CLIENT so the closes
|
|
471
|
+
// commit atomically with the task's flip to 'shipped'. Swallows its own failure
|
|
472
|
+
// and reports it in the returned line — a criterion close must never fail a
|
|
473
|
+
// ship, and the reconciler's unscoped sweep retries every tick. Returns a
|
|
474
|
+
// summary string, or null when nothing closed (the common case).
|
|
475
|
+
async function closeCompletedWorkForShip(client, taskId, log = console.log) {
|
|
476
|
+
let line;
|
|
477
|
+
try {
|
|
478
|
+
line = describeAutoClose(await autoSatisfyShippedCriteria(client, { taskId }));
|
|
479
|
+
} catch (e) {
|
|
480
|
+
line = `auto-satisfy skipped (sweep retries): ${e && e.message}`;
|
|
481
|
+
}
|
|
482
|
+
// Logged HERE, which means INSIDE the caller's transaction — the one honest
|
|
483
|
+
// wart in this design. shipTask's other observability is deliberately
|
|
484
|
+
// post-commit, so on the rare rolled-back ship this line describes closes that
|
|
485
|
+
// did not persist. Accepted because the alternative was threading the summary
|
|
486
|
+
// back out through withTx, and those ~5 extra lines land in db.js, which is
|
|
487
|
+
// over the fitness oversize threshold (shrink-only policy). A rolled-back ship
|
|
488
|
+
// fails loudly on its own, so a stray line here cannot be mistaken for success.
|
|
489
|
+
if (line) log(`[gds] shipTask ${taskId}: ${line}`);
|
|
490
|
+
return line || null;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function describeAutoClose(closed) {
|
|
494
|
+
const criteria = closed?.criteria || [];
|
|
495
|
+
const goals = closed?.goals || [];
|
|
496
|
+
if (!criteria.length && !goals.length) return null;
|
|
497
|
+
const c = criteria.map((x) => x.criterion_id).join(', ') || 'none';
|
|
498
|
+
const g = goals.map((x) => `${x.id} "${x.title}"`).join(', ') || 'none';
|
|
499
|
+
return `auto-satisfied criteria [${c}]; auto-ACHIEVED goals [${g}]`;
|
|
500
|
+
}
|
|
501
|
+
|
|
360
502
|
module.exports = {
|
|
361
503
|
listCriteria,
|
|
362
504
|
listGoalsForVersion,
|
|
@@ -367,6 +509,9 @@ module.exports = {
|
|
|
367
509
|
buildCriterionProgress,
|
|
368
510
|
buildGoals,
|
|
369
511
|
isPendingReview,
|
|
512
|
+
autoSatisfyShippedCriteria,
|
|
513
|
+
describeAutoClose,
|
|
514
|
+
closeCompletedWorkForShip,
|
|
370
515
|
markCriterion,
|
|
371
516
|
unmarkCriterion,
|
|
372
517
|
};
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// modules/lifecycle/goal-suggest.js — "which goal should this work live in?",
|
|
4
|
+
// answered BEFORE anything is created (task 1003062 / R07 of goal 1000071).
|
|
5
|
+
//
|
|
6
|
+
// THE GAP. `buildGoalAdvisory` (goal-advisory.js) already answers this — but only
|
|
7
|
+
// AFTER a task exists, as a hint attached to the 201. Every consumer R07 feeds
|
|
8
|
+
// needs it BEFORE: the hall filing surface offering a goal picker (R08), the CLI
|
|
9
|
+
// asking at capture time (R09), and the Discord suggestion (R11) all have a draft
|
|
10
|
+
// title and no row yet. So the candidate logic is lifted into a pure read that
|
|
11
|
+
// creates nothing.
|
|
12
|
+
//
|
|
13
|
+
// WHY THIS RANKS, rather than returning "the first five open goals". Unranked
|
|
14
|
+
// candidates are useless here: BONGOS-V1 carries dozens of open goals, so an
|
|
15
|
+
// arbitrary five is noise dressed as a suggestion. Ranking is what makes it a
|
|
16
|
+
// suggester. It reuses `tokenize` from criterion-suggest.js — a sibling, already
|
|
17
|
+
// covering the weighting idea (title terms 2, body terms 1) and already tested —
|
|
18
|
+
// so there is one notion of "these two texts are about the same thing" in the
|
|
19
|
+
// module, not two.
|
|
20
|
+
//
|
|
21
|
+
// WHAT A GOAL IS MATCHED ON: its title and its scope_modules. Deliberately NOT
|
|
22
|
+
// its description — a goal description is where a whole planning spec lives (goal
|
|
23
|
+
// 1000062 carries an entire decision record in its), so it shares tokens with
|
|
24
|
+
// almost any draft and would flatten every score toward the same number. Modules
|
|
25
|
+
// are included because they are a strong, cheap signal: a draft about the hall
|
|
26
|
+
// really does belong with a goal scoped to `hall-ui`.
|
|
27
|
+
|
|
28
|
+
const { tokenize } = require('./criterion-suggest.js');
|
|
29
|
+
const { candidateGoals } = require('./goal-advisory.js');
|
|
30
|
+
const api = require('../../src/module-api');
|
|
31
|
+
// Through the published logger seam, not a raw console call (est-advisory.js precedent).
|
|
32
|
+
const proseErr = (err) => api.logger('lifecycle').error({ err: err && err.message }, 'GET /goals/suggestions failed');
|
|
33
|
+
|
|
34
|
+
// Matches suggestCriteria's default so the two suggesters feel the same.
|
|
35
|
+
const SUGGEST_LIMIT = 5;
|
|
36
|
+
|
|
37
|
+
// Rank open goals against a DRAFT capture. Pure — no DB, no writes; the caller
|
|
38
|
+
// supplies the goal rows.
|
|
39
|
+
//
|
|
40
|
+
// goals — open goal rows { id, title, scope_modules }
|
|
41
|
+
// draft — { title, body } (body optional: a capture's prose, weighted lower)
|
|
42
|
+
// opts — { versionId, limit, includeZero }
|
|
43
|
+
//
|
|
44
|
+
// Returns [{ id, title, scope_modules, score, matched_terms }] best-first, ties
|
|
45
|
+
// broken by ascending id so output is stable for a picker.
|
|
46
|
+
//
|
|
47
|
+
// `includeZero` defaults FALSE, following suggestCriteria: a draft that matches
|
|
48
|
+
// nothing returns [] — "no confident suggestion" — rather than five arbitrary
|
|
49
|
+
// goals. The route reports the open-goal count alongside so a caller can still
|
|
50
|
+
// offer "browse all" without this function pretending to a confidence it lacks.
|
|
51
|
+
function suggestGoals(goals, draft = {}, opts = {}) {
|
|
52
|
+
const { title = '', body = '' } = draft;
|
|
53
|
+
const { versionId = null, limit = SUGGEST_LIMIT, includeZero = false } = opts;
|
|
54
|
+
|
|
55
|
+
// Weighted term map: body terms at 1, title terms override to 2 (a term in
|
|
56
|
+
// both IS a title term — the stronger signal wins). suggestCriteria's rule.
|
|
57
|
+
const weights = new Map();
|
|
58
|
+
for (const t of tokenize(body)) weights.set(t, 1);
|
|
59
|
+
for (const t of tokenize(title)) weights.set(t, 2);
|
|
60
|
+
|
|
61
|
+
// candidateGoals drops the "<version> — general" catch-all and normalises the
|
|
62
|
+
// row shape. Reused rather than re-filtered so "which goals may be suggested"
|
|
63
|
+
// has one definition — the catch-all is never a suggestion, it is the fallback
|
|
64
|
+
// when there is no home, which is a different question (R03 owns that).
|
|
65
|
+
const ranked = candidateGoals(goals, versionId).map((g) => {
|
|
66
|
+
const goalTerms = new Set([
|
|
67
|
+
...tokenize(g.title),
|
|
68
|
+
// A module key like `hall-ui` tokenizes to ['hall','ui']; 'ui' is dropped
|
|
69
|
+
// by the >=3-char filter, which is fine — 'hall' is the signal.
|
|
70
|
+
...tokenize((g.scope_modules || []).join(' ')),
|
|
71
|
+
]);
|
|
72
|
+
let score = 0;
|
|
73
|
+
const matched = [];
|
|
74
|
+
for (const [term, w] of weights) {
|
|
75
|
+
if (goalTerms.has(term)) { score += w; matched.push(term); }
|
|
76
|
+
}
|
|
77
|
+
return { ...g, score, matched_terms: matched.sort() };
|
|
78
|
+
}).sort((a, b) => (b.score - a.score) || (Number(a.id) - Number(b.id)));
|
|
79
|
+
|
|
80
|
+
const kept = includeZero ? ranked : ranked.filter((r) => r.score > 0);
|
|
81
|
+
return kept.slice(0, Math.max(0, limit));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
// goalSuggestHandler — GET /goals/suggestions. Handler lives here, not inline in
|
|
86
|
+
// routes/goals.js, because that file is over the fitness oversize threshold and
|
|
87
|
+
// the ratchet is shrink-only; collaborators are injected so this module still
|
|
88
|
+
// requires nothing but its siblings.
|
|
89
|
+
//
|
|
90
|
+
// Creates NOTHING — that is the point of R07. A caller with a draft title gets
|
|
91
|
+
// candidates and decides; POST /inbox (R01) is what actually routes.
|
|
92
|
+
//
|
|
93
|
+
// VISIBILITY, stated rather than silently decided: this returns open goals
|
|
94
|
+
// without filtering on `visibility`, which MATCHES the existing GET /goals — that
|
|
95
|
+
// route already lists private goals' titles to any signed-in builder
|
|
96
|
+
// (db.listGoals has no visibility predicate). So this neither widens nor narrows
|
|
97
|
+
// the status quo. It is worth knowing that the status quo is wider than the
|
|
98
|
+
// private-goal leak rule the CLAIM refusal follows (goal-authz
|
|
99
|
+
// goalMembershipClaimRefusal withholds a private goal's title); reconciling the
|
|
100
|
+
// two is a real decision about the goal board as a whole, not something a
|
|
101
|
+
// suggester should settle on its own.
|
|
102
|
+
function goalSuggestHandler({ db, LIMITS }) {
|
|
103
|
+
return async function getGoalSuggestions(req, res) {
|
|
104
|
+
const versionId = String(req.query.version || '').trim();
|
|
105
|
+
if (!versionId) return res.fail('version_required', 400, { message: 'Pass ?version=<version_id> — suggestions are scoped to one version.' });
|
|
106
|
+
const title = String(req.query.title || '').slice(0, (LIMITS && LIMITS.TITLE) || 200);
|
|
107
|
+
const body = String(req.query.body || '').slice(0, 4000);
|
|
108
|
+
if (!title && !body) {
|
|
109
|
+
return res.fail('draft_text_required', 400, { message: 'Pass ?title= (and optionally ?body=) — there is nothing to rank against otherwise.' });
|
|
110
|
+
}
|
|
111
|
+
const rawLimit = Number(req.query.limit);
|
|
112
|
+
const limit = Number.isFinite(rawLimit) ? Math.min(Math.max(1, Math.trunc(rawLimit)), 20) : SUGGEST_LIMIT;
|
|
113
|
+
// Wrapped like criteriaSuggestHandler below: without it a db error escapes to
|
|
114
|
+
// Express's default handler, which can put internal detail on a JSON API
|
|
115
|
+
// response. The sibling in this same file already did this; not matching it
|
|
116
|
+
// was an oversight, not a decision.
|
|
117
|
+
try {
|
|
118
|
+
const open = await db.listGoals({ versionId, status: 'open' });
|
|
119
|
+
const suggestions = suggestGoals(open, { title, body }, { versionId, limit });
|
|
120
|
+
return res.json({
|
|
121
|
+
version_id: versionId,
|
|
122
|
+
// `kind` is echoed, NOT ranked on. Kind decides how a capture LANDS (the
|
|
123
|
+
// R05 landing matrix: a bug hard-lands, a feature soft-lands); it does not
|
|
124
|
+
// indicate WHICH goal the work belongs to. Saying so here stops a caller
|
|
125
|
+
// reading the echo as a ranking input.
|
|
126
|
+
kind: req.query.kind ? String(req.query.kind).slice(0, 64) : null,
|
|
127
|
+
suggestions,
|
|
128
|
+
// So a caller can offer "browse all" honestly when nothing scored: an empty
|
|
129
|
+
// `suggestions` means "no confident match", never "this version has no goals".
|
|
130
|
+
open_goal_count: (open || []).length,
|
|
131
|
+
created: null,
|
|
132
|
+
});
|
|
133
|
+
} catch (err) {
|
|
134
|
+
proseErr(err);
|
|
135
|
+
return res.fail('suggest_failed', { status: 500, message: 'internal error' });
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// criteriaSuggestHandler — GET /goals/:id/suggest-criteria (task 2165), moved
|
|
141
|
+
// here in task 1003062. Same reason as its sibling above: routes/goals.js is over
|
|
142
|
+
// the fitness oversize threshold and the ratchet is shrink-only, so adding the
|
|
143
|
+
// goal suggester there had to be PAID FOR — and paying with a cohesion
|
|
144
|
+
// improvement beats shaving comments. It belongs beside the other suggester
|
|
145
|
+
// anyway: both answer "what should this draft attach to", one at goal level and
|
|
146
|
+
// one at criterion level. Behaviour byte-identical; only the registration line
|
|
147
|
+
// stays in the route file so route-rank-check still classifies it.
|
|
148
|
+
function criteriaSuggestHandler({ db, suggestCriteria, parseId }) {
|
|
149
|
+
return async function getSuggestCriteria(req, res) {
|
|
150
|
+
const id = parseId(req, res, { code: 'bad_goal_id' });
|
|
151
|
+
if (id === null) return;
|
|
152
|
+
try {
|
|
153
|
+
const goal = await db.getGoal(id);
|
|
154
|
+
if (!goal) return res.fail('goal_not_found', 404);
|
|
155
|
+
const criteria = await db.listCriteriaForGoal(id);
|
|
156
|
+
const limit = Math.min(10, Math.max(1, Number(req.query.limit) || 3));
|
|
157
|
+
const suggestions = suggestCriteria(
|
|
158
|
+
criteria,
|
|
159
|
+
{ title: req.query.title, description: req.query.description },
|
|
160
|
+
{ limit }
|
|
161
|
+
);
|
|
162
|
+
return res.json({ goal_id: goal.id, suggestions });
|
|
163
|
+
} catch (err) {
|
|
164
|
+
// Same seam as its sibling above — one error-handling pattern per file.
|
|
165
|
+
api.logger('lifecycle').error({ err: err && err.message }, 'GET /goals/:id/suggest-criteria failed');
|
|
166
|
+
return res.fail('fetch_failed', { status: 500, message: 'internal error' });
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
module.exports = { suggestGoals, goalSuggestHandler, criteriaSuggestHandler, SUGGEST_LIMIT };
|
|
@@ -777,9 +777,17 @@ async function runSweep(deps = {}) {
|
|
|
777
777
|
// prevents any double-count if the client also posted. Unlike the confirmed→
|
|
778
778
|
// shipped work, this is PURE DB — it needs no push credential — so it runs even
|
|
779
779
|
// on a host where getPublishStatus would 503. Best-effort + injected-deps pure.
|
|
780
|
+
// One logger resolver for every sweep (task 1002069). Was copy-pasted per sweep;
|
|
781
|
+
// three copies of `deps.log || console.log` is three places to drift.
|
|
782
|
+
function loggers(deps = {}) {
|
|
783
|
+
return { logFn: deps.log || console.log, errFn: deps.error || console.error };
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
// A single crash handler for the tick's fire-and-forget sweeps — same reason.
|
|
787
|
+
const crashHandler = (name) => (e) => loggers().errFn(`[publish-reconciler] ${name} crashed: ${e && e.message}`);
|
|
788
|
+
|
|
780
789
|
async function runCostSweep(deps = {}) {
|
|
781
|
-
const logFn = deps
|
|
782
|
-
const errFn = deps.error || console.error;
|
|
790
|
+
const { logFn, errFn } = loggers(deps);
|
|
783
791
|
// reconcileSessionCosts carved into modules/economy/ (BV1.R77 / ADR 0093 §1-2):
|
|
784
792
|
// prefer an injected deps.db (the test seam — tests inject a db with the
|
|
785
793
|
// method), else resolve the `reward` kernel port (production; economy default-on).
|
|
@@ -804,6 +812,40 @@ async function runCostSweep(deps = {}) {
|
|
|
804
812
|
}
|
|
805
813
|
}
|
|
806
814
|
|
|
815
|
+
// runCriterionSweep — the UNSCOPED half of auto-satisfy (task 1002069 / ADR 0183).
|
|
816
|
+
// shipTask closes what its own ship completed, in its own txn; this closes
|
|
817
|
+
// everything else, on every tick. Two jobs, and the second is why it is not
|
|
818
|
+
// merely belt-and-braces:
|
|
819
|
+
// BACKLOG DRAIN — every criterion whose gating tasks shipped BEFORE this
|
|
820
|
+
// feature existed. Without the sweep those stay unsatisfied forever, since
|
|
821
|
+
// nothing will ship against them again.
|
|
822
|
+
// SELF-HEAL — a criterion linked AFTER its tasks shipped, a task status changed
|
|
823
|
+
// by hand, or a ship whose in-txn close was skipped by the best-effort catch.
|
|
824
|
+
// Pure DB like runCostSweep — no push credential — so it runs on hosts where
|
|
825
|
+
// getPublishStatus 503s. Idempotent: it only reads satisfied=false rows.
|
|
826
|
+
async function runCriterionSweep(deps = {}) {
|
|
827
|
+
const { logFn, errFn } = loggers(deps);
|
|
828
|
+
// Injected deps.doneWhen is the test seam; the sibling module is production.
|
|
829
|
+
const dw = (deps.doneWhen && typeof deps.doneWhen.autoSatisfyShippedCriteria === 'function')
|
|
830
|
+
? deps.doneWhen
|
|
831
|
+
: require('./done-when.js');
|
|
832
|
+
const exec = deps.pool || seams.pool;
|
|
833
|
+
if (typeof dw.autoSatisfyShippedCriteria !== 'function') return { skipped: 'unsupported' };
|
|
834
|
+
try {
|
|
835
|
+
const closed = await dw.autoSatisfyShippedCriteria(exec, {});
|
|
836
|
+
for (const c of closed.criteria) {
|
|
837
|
+
logFn(`[publish-reconciler] criterion ${c.criterion_id} (${c.version_id}) auto-satisfied — all gating tasks shipped`);
|
|
838
|
+
}
|
|
839
|
+
for (const g of closed.goals) {
|
|
840
|
+
logFn(`[publish-reconciler] goal ${g.id} "${g.title}" auto-ACHIEVED — every criterion satisfied`);
|
|
841
|
+
}
|
|
842
|
+
return closed;
|
|
843
|
+
} catch (e) {
|
|
844
|
+
errFn(`[publish-reconciler] criterion sweep (non-blocking): ${e && e.message}`);
|
|
845
|
+
return { skipped: 'error', error: e && e.message };
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
|
|
807
849
|
// Start the periodic sweep. Idempotent (a second call is a no-op). Kill-switched
|
|
808
850
|
// by PUBLISH_RECONCILER_DISABLED=1 (purely additive bookkeeping, so it's ON by
|
|
809
851
|
// default whenever a push credential is configured; the route's getPublishStatus
|
|
@@ -816,11 +858,13 @@ function start(deps = {}) {
|
|
|
816
858
|
}
|
|
817
859
|
if (intervalTimer) return; // already started
|
|
818
860
|
|
|
861
|
+
// Each sweep is INDEPENDENT on purpose: the cost (task 1366) and criterion
|
|
862
|
+
// (task 1002069) sweeps are pure DB — no push credential — and a failure in
|
|
863
|
+
// either must never touch the confirmed→shipped work.
|
|
819
864
|
const tick = () => {
|
|
820
|
-
runSweep(deps).catch((
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
runCostSweep(deps).catch((e) => console.error(`[publish-reconciler] cost sweep crashed: ${e && e.message}`));
|
|
865
|
+
runSweep(deps).catch(crashHandler('sweep'));
|
|
866
|
+
runCostSweep(deps).catch(crashHandler('cost sweep'));
|
|
867
|
+
runCriterionSweep(deps).catch(crashHandler('criterion sweep'));
|
|
824
868
|
};
|
|
825
869
|
|
|
826
870
|
firstTimer = setTimeout(tick, FIRST_SWEEP_DELAY_MS);
|
|
@@ -857,6 +901,7 @@ module.exports = {
|
|
|
857
901
|
stop,
|
|
858
902
|
runSweep,
|
|
859
903
|
runCostSweep,
|
|
904
|
+
runCriterionSweep,
|
|
860
905
|
couldBeStaleGenerated,
|
|
861
906
|
describeTarget,
|
|
862
907
|
INTERVAL_MS,
|
|
@@ -26,6 +26,7 @@ const api = require('../../../src/module-api');
|
|
|
26
26
|
const auth = api;
|
|
27
27
|
const db = require('../db');
|
|
28
28
|
const hierarchy = require('../hierarchy');
|
|
29
|
+
const goalSuggest = require('../goal-suggest.js'); // task 1003062 — pre-creation goal suggester
|
|
29
30
|
const goalEdits = require('../goal-edits.js'); // task 1003102 — goal self-edit handlers
|
|
30
31
|
const { suggestCriteria } = require('../criterion-suggest');
|
|
31
32
|
const { classifyTaskKind, VALID_KINDS, VALID_DISCIPLINES, TASK_FIELD_BOUNDS } = require('../task-classifier');
|
|
@@ -535,6 +536,13 @@ module.exports = function buildGoalsRouter() {
|
|
|
535
536
|
}
|
|
536
537
|
});
|
|
537
538
|
|
|
539
|
+
// GET /goals/suggestions — "which goal should this draft live in?", answered
|
|
540
|
+
// before anything is created (task 1003062 / R07). Handler + the ranking and
|
|
541
|
+
// visibility rationale: ../goal-suggest.js. Mounted BEFORE GET /goals/:id so
|
|
542
|
+
// 'suggestions' is never captured as an id.
|
|
543
|
+
// rank: any-builder — a read of open goal titles, same surface as GET /goals.
|
|
544
|
+
router.get('/goals/suggestions', auth.requireBuilder, goalSuggest.goalSuggestHandler({ db, LIMITS }));
|
|
545
|
+
|
|
538
546
|
// GET /goals/inbox — the caller's private-goals inbox (task 1952 / ADR 0112):
|
|
539
547
|
// { invitations: invites addressed to me (accept/decline); joinRequests: pending
|
|
540
548
|
// join-requests on goals I own or manage (approve/deny); myPendingRequests: my own
|
|
@@ -578,25 +586,7 @@ module.exports = function buildGoalsRouter() {
|
|
|
578
586
|
// task, so a create flow can SUGGEST which done-when criterion to link (the
|
|
579
587
|
// author confirms/overrides — nothing is auto-linked). Read-only + advisory;
|
|
580
588
|
// rank: any-builder (same as GET /goals/:id).
|
|
581
|
-
router.get('/goals/:id/suggest-criteria', auth.requireBuilder,
|
|
582
|
-
const id = parseId(req, res, { code: 'bad_goal_id' });
|
|
583
|
-
if (id === null) return;
|
|
584
|
-
try {
|
|
585
|
-
const goal = await db.getGoal(id);
|
|
586
|
-
if (!goal) return res.fail('goal_not_found', 404);
|
|
587
|
-
const criteria = await db.listCriteriaForGoal(id);
|
|
588
|
-
const limit = Math.min(10, Math.max(1, Number(req.query.limit) || 3));
|
|
589
|
-
const suggestions = suggestCriteria(
|
|
590
|
-
criteria,
|
|
591
|
-
{ title: req.query.title, description: req.query.description },
|
|
592
|
-
{ limit }
|
|
593
|
-
);
|
|
594
|
-
return res.json({ goal_id: goal.id, suggestions });
|
|
595
|
-
} catch (err) {
|
|
596
|
-
console.error('[gds] GET /goals/:id/suggest-criteria', err);
|
|
597
|
-
return res.fail('fetch_failed', { status: 500, message: 'internal error' });
|
|
598
|
-
}
|
|
599
|
-
});
|
|
589
|
+
router.get('/goals/:id/suggest-criteria', auth.requireBuilder, goalSuggest.criteriaSuggestHandler({ db, suggestCriteria, parseId }));
|
|
600
590
|
|
|
601
591
|
// POST /goals/:id/join — join an open goal.
|
|
602
592
|
// rank: any-builder — open join for a NORMAL goal. A PROTECTED-scope goal is
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.289",
|
|
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.289",
|
|
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.289",
|
|
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",
|
|
@@ -362,6 +362,9 @@ const MODULE_EXPECTED_RANKS = {
|
|
|
362
362
|
// Pinning catches an accidental requireRank re-gate that would lock builders
|
|
363
363
|
// out of asking/accepting, and any gate removal.
|
|
364
364
|
'GET /goals/inbox': 'any-builder',
|
|
365
|
+
// task 1003062 (R07): a pre-creation READ of open goal titles — same surface
|
|
366
|
+
// and same rank as GET /goals. Creates nothing.
|
|
367
|
+
'GET /goals/suggestions': 'any-builder',
|
|
365
368
|
'PATCH /goals/:id/visibility': 'any-builder',
|
|
366
369
|
// task 1003102: a goal's own authority (owner|manager|Archon, in-handler
|
|
367
370
|
// authorizeGoalManagement) corrects its own title/description — same shape
|
|
@@ -506,8 +509,20 @@ function classifyGate(lines, i, { floors } = {}) {
|
|
|
506
509
|
// (not any `=>`) avoids cutting at an inline-arrow middleware like
|
|
507
510
|
// `rateLimit({ key: (req) => req.ip })`. If there's no `=> {` in the window
|
|
508
511
|
// (e.g. a `function` handler), fall back to the full window — prior behaviour.
|
|
509
|
-
|
|
510
|
-
|
|
512
|
+
// task 1003062: FIRST clamp the window to THIS route's own declaration. The
|
|
513
|
+
// `=> {` truncation below only fires for an inline-arrow handler; a route whose
|
|
514
|
+
// handler is a FACTORY CALL — `router.get(p, auth.requireBuilder, mk({...}))` —
|
|
515
|
+
// contains no `=> {`, so the old code fell back to the full 20 lines and
|
|
516
|
+
// absorbed the NEXT route's requirePermission, recording an any-builder read as
|
|
517
|
+
// metic+archon. Wrong in the tightening direction, but still wrong: the pin and
|
|
518
|
+
// the audit then describe a gate that is not there. Factory handlers became
|
|
519
|
+
// common because the fitness oversize ratchet pushes handler bodies out of large
|
|
520
|
+
// route files, so this is not an exotic shape. A window must never cross into a
|
|
521
|
+
// sibling declaration.
|
|
522
|
+
const nextRouteAt = window.search(/\n\s*router\.(get|post|patch|put|delete|use)\s*\(/);
|
|
523
|
+
const own = nextRouteAt === -1 ? window : window.slice(0, nextRouteAt);
|
|
524
|
+
const handlerAt = own.search(/=>\s*\{/);
|
|
525
|
+
const tail = handlerAt === -1 ? own : own.slice(0, handlerAt);
|
|
511
526
|
|
|
512
527
|
const perms = permissionKeysIn(tail);
|
|
513
528
|
const byRank = rankFromRankGate(tail);
|
package/src/module-api.js
CHANGED
|
@@ -1001,8 +1001,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
1001
1001
|
// landed since 1.19.285 with no explicit bump. run 32533647172. (task 1002620)
|
|
1002
1002
|
// 1.19.287 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1003
1003
|
// landed since 1.19.286 with no explicit bump. run 32539649191. (task 1002620)
|
|
1004
|
+
// 1.19.288 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1005
|
+
// landed since 1.19.287 with no explicit bump. run 32546561232. (task 1002620)
|
|
1006
|
+
// 1.19.289 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1007
|
+
// landed since 1.19.288 with no explicit bump. run 32549912116. (task 1002620)
|
|
1004
1008
|
// ---------------------------------------------------------------------------
|
|
1005
|
-
const CORE_VERSION = '1.19.
|
|
1009
|
+
const CORE_VERSION = '1.19.289'; // CI auto-patch carrier (ADR 0161); see changelog above
|
|
1006
1010
|
|
|
1007
1011
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
1008
1012
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|