@bongos/core 1.19.814 → 1.19.816
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 +31 -26
- package/docs/module-api-changelog.md +4 -0
- package/modules/agents/lib/validate.js +1 -1
- package/modules/discord/discord-channels.js +80 -5
- package/modules/discord/routes/discord.js +14 -2
- package/modules/ideas/inbox.js +29 -1
- package/modules/lifecycle/cascade.js +134 -19
- package/modules/lifecycle/db-goals.js +36 -0
- package/modules/lifecycle/db.js +2 -0
- package/modules/lifecycle/goal-suggest.js +75 -9
- package/modules/lifecycle/routes/lifecycle.js +18 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/capture.js +43 -8
- package/src/module-api.js +1 -1
- package/tests/agents_validate.mjs +33 -2
- package/tests/capture_goal_routing.mjs +65 -1
- package/tests/discord_channels.mjs +166 -0
- package/tests/goal_suggest.mjs +148 -1
- package/tests/idea_cascade.mjs +211 -0
- package/tests/task_goal_required.mjs +15 -3
|
@@ -832,6 +832,41 @@ async function listCriteriaForGoal(goalId, deps = {}) {
|
|
|
832
832
|
}));
|
|
833
833
|
}
|
|
834
834
|
|
|
835
|
+
// listCriterionTextForGoals — every goal's done-when criterion prose, keyed by
|
|
836
|
+
// goal id, in ONE query (task 1003859). The goal suggester ranks on this.
|
|
837
|
+
//
|
|
838
|
+
// WHY BULK. The suggester scores all open goals for a version at once — eleven
|
|
839
|
+
// of them on BONGOS-V2 — so per-goal reads would be eleven round trips on a
|
|
840
|
+
// path that runs at capture time, for prose that is a few hundred bytes a goal.
|
|
841
|
+
//
|
|
842
|
+
// NO INDEX ON goal_id, DELIBERATELY. done_when_criteria carries a handful of rows
|
|
843
|
+
// per goal and low hundreds in total — a scan of it is cheaper than the index it
|
|
844
|
+
// would need maintaining, and adding one would mint a migration (a reserved number
|
|
845
|
+
// and human review) to speed up a read that is already the smallest thing on this
|
|
846
|
+
// path. idx_done_when_version exists for the version-scoped read; revisit if the
|
|
847
|
+
// table ever grows a digit.
|
|
848
|
+
//
|
|
849
|
+
// WHY NOT listCriteriaForGoal. That read carries linked-task counts and the
|
|
850
|
+
// pending_review derivation, none of which a text ranker wants; this returns the
|
|
851
|
+
// criterion prose and nothing else.
|
|
852
|
+
async function listCriterionTextForGoals(goalIds, deps = {}) {
|
|
853
|
+
const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
|
|
854
|
+
const ids = (goalIds || []).map((g) => String(g)).filter(Boolean);
|
|
855
|
+
if (ids.length === 0) return new Map();
|
|
856
|
+
const { rows } = await activePool.query(
|
|
857
|
+
`SELECT goal_id, criterion_md
|
|
858
|
+
FROM done_when_criteria
|
|
859
|
+
WHERE goal_id = ANY($1::bigint[])`,
|
|
860
|
+
[ids]
|
|
861
|
+
);
|
|
862
|
+
const byGoal = new Map();
|
|
863
|
+
for (const r of rows) {
|
|
864
|
+
const k = String(r.goal_id);
|
|
865
|
+
byGoal.set(k, (byGoal.get(k) ? byGoal.get(k) + ' ' : '') + String(r.criterion_md || ''));
|
|
866
|
+
}
|
|
867
|
+
return byGoal;
|
|
868
|
+
}
|
|
869
|
+
|
|
835
870
|
// isGoalMember — is this builder a member of this goal? The membership predicate
|
|
836
871
|
// behind the bounded-authority gates (BV1.R64 ask-to-widen; the R60 authoring
|
|
837
872
|
// gate reuses the same idea). Returns a plain boolean.
|
|
@@ -1251,6 +1286,7 @@ module.exports = {
|
|
|
1251
1286
|
isGoalMember,
|
|
1252
1287
|
listActiveClaimsForTasks,
|
|
1253
1288
|
listCriteriaForGoal,
|
|
1289
|
+
listCriterionTextForGoals,
|
|
1254
1290
|
openTasksInGoal,
|
|
1255
1291
|
listGoalMembers,
|
|
1256
1292
|
listGoals,
|
package/modules/lifecycle/db.js
CHANGED
|
@@ -88,6 +88,7 @@ const {
|
|
|
88
88
|
isGoalMember,
|
|
89
89
|
listActiveClaimsForTasks,
|
|
90
90
|
listCriteriaForGoal,
|
|
91
|
+
listCriterionTextForGoals,
|
|
91
92
|
archiveGoalWithDispositions,
|
|
92
93
|
openTasksInGoal,
|
|
93
94
|
listGoalMembers,
|
|
@@ -267,6 +268,7 @@ module.exports = {
|
|
|
267
268
|
listInboxFor,
|
|
268
269
|
listPendingRequestsForGoal,
|
|
269
270
|
listCriteriaForGoal,
|
|
271
|
+
listCriterionTextForGoals,
|
|
270
272
|
archiveGoalWithDispositions,
|
|
271
273
|
openTasksInGoal,
|
|
272
274
|
xenosClaimAllowed,
|
|
@@ -18,12 +18,25 @@
|
|
|
18
18
|
// so there is one notion of "these two texts are about the same thing" in the
|
|
19
19
|
// module, not two.
|
|
20
20
|
//
|
|
21
|
-
// WHAT A GOAL IS MATCHED ON: its title
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
21
|
+
// WHAT A GOAL IS MATCHED ON: its title, its scope_modules, and its DONE-WHEN
|
|
22
|
+
// CRITERIA (task 1003859). Criteria were added because title-only ranking went
|
|
23
|
+
// dead the moment the goal board adopted ADR 0264's naming: all eleven open
|
|
24
|
+
// BONGOS-V2 goals are titled 'Working area N — <subject>', so a real subject word
|
|
25
|
+
// in a draft — 'release', 'grader', 'dependency' — had almost nothing to overlap,
|
|
26
|
+
// every goal scored under threshold, and the suggester returned [] for every idea
|
|
27
|
+
// walked at the 2026-09-12 triage. It also left suggested_goal_id NULL at capture
|
|
28
|
+
// time on every BONGOS-V2 filing, so triage inherited nothing either. The criteria
|
|
29
|
+
// carry the subject vocabulary the titles stopped carrying.
|
|
30
|
+
//
|
|
31
|
+
// Criterion terms are weighted BELOW title terms (see CRITERION_WEIGHT): a goal
|
|
32
|
+
// named for the subject is still the stronger claim, and criteria are longer text
|
|
33
|
+
// that would otherwise swamp it.
|
|
34
|
+
//
|
|
35
|
+
// Still deliberately NOT the goal DESCRIPTION — a goal description is where a whole
|
|
36
|
+
// planning spec lives (goal 1000062 carries an entire decision record in its), so
|
|
37
|
+
// it shares tokens with almost any draft and would flatten every score toward the
|
|
38
|
+
// same number. Modules are included because they are a strong, cheap signal: a
|
|
39
|
+
// draft about the hall really does belong with a goal scoped to `hall-ui`.
|
|
27
40
|
|
|
28
41
|
const { tokenize } = require('./criterion-suggest.js');
|
|
29
42
|
const { versionAcceptsNewWork } = require('./db-tasks.js');
|
|
@@ -38,10 +51,47 @@ const captureErr = (err) => api.logger('lifecycle').error({ err: err && err.mess
|
|
|
38
51
|
// Matches suggestCriteria's default so the two suggesters feel the same.
|
|
39
52
|
const SUGGEST_LIMIT = 5;
|
|
40
53
|
|
|
54
|
+
// A criterion term counts for less than the same term in the title. Criteria are
|
|
55
|
+
// several sentences per goal against a title's handful of words, so equal weight
|
|
56
|
+
// would let a goal that merely MENTIONS a subject outrank one NAMED for it.
|
|
57
|
+
const CRITERION_WEIGHT = 0.5;
|
|
58
|
+
|
|
59
|
+
// rankableCriteria — the criterion prose the ranker may read, for the goals it is
|
|
60
|
+
// about to score. Both callers go through this, so the two rules below have ONE
|
|
61
|
+
// home and cannot drift apart.
|
|
62
|
+
//
|
|
63
|
+
// RULE 1 — PRIVATE GOALS ARE EXCLUDED. This function's own header explains that
|
|
64
|
+
// GET /goals (and therefore this suggester) already lists private goals' TITLES to
|
|
65
|
+
// any signed-in builder, and that reconciling that is a decision about the goal
|
|
66
|
+
// board as a whole rather than something a suggester settles. Criteria are a
|
|
67
|
+
// different order of exposure: score + matched_terms make a ranker into a
|
|
68
|
+
// word-guessing oracle, so ranking on a private goal's done-when prose would let
|
|
69
|
+
// any builder probe the CONTENT of work they cannot see, one candidate word per
|
|
70
|
+
// request. So this addition stops at the visibility line the status quo already
|
|
71
|
+
// draws — a private goal keeps ranking exactly as it did before (title + modules),
|
|
72
|
+
// and nothing new is disclosed. Widening or narrowing the title-level status quo
|
|
73
|
+
// stays the separate decision it was.
|
|
74
|
+
//
|
|
75
|
+
// RULE 2 — BEST EFFORT. A criteria read that fails degrades to title-only ranking,
|
|
76
|
+
// which is what this path did before the criteria existed. A suggester is advisory
|
|
77
|
+
// everywhere it is called; taking down a bug report or 500ing a picker to protect
|
|
78
|
+
// a ranking refinement would be the wrong trade.
|
|
79
|
+
async function rankableCriteria(db, goals, onError) {
|
|
80
|
+
if (typeof db.listCriterionTextForGoals !== 'function') return null;
|
|
81
|
+
const rankable = (goals || []).filter((g) => g && g.visibility !== 'private');
|
|
82
|
+
if (rankable.length === 0) return null;
|
|
83
|
+
try {
|
|
84
|
+
return await db.listCriterionTextForGoals(rankable.map((g) => g.id));
|
|
85
|
+
} catch (err) { onError(err); return null; }
|
|
86
|
+
}
|
|
87
|
+
|
|
41
88
|
// Rank open goals against a DRAFT capture. Pure — no DB, no writes; the caller
|
|
42
89
|
// supplies the goal rows.
|
|
43
90
|
//
|
|
44
91
|
// goals — open goal rows { id, title, scope_modules }
|
|
92
|
+
// opts.criteriaByGoal — Map<goalId, criterion prose> (db.listCriterionTextForGoals).
|
|
93
|
+
// Optional: omitted, this ranks exactly as it did before — the pure
|
|
94
|
+
// function stays callable with no DB anywhere near it.
|
|
45
95
|
// draft — { title, body } (body optional: a capture's prose, weighted lower)
|
|
46
96
|
// opts — { versionId, limit, includeZero }
|
|
47
97
|
//
|
|
@@ -54,7 +104,7 @@ const SUGGEST_LIMIT = 5;
|
|
|
54
104
|
// offer "browse all" without this function pretending to a confidence it lacks.
|
|
55
105
|
function suggestGoals(goals, draft = {}, opts = {}) {
|
|
56
106
|
const { title = '', body = '' } = draft;
|
|
57
|
-
const { versionId = null, limit = SUGGEST_LIMIT, includeZero = false } = opts;
|
|
107
|
+
const { versionId = null, limit = SUGGEST_LIMIT, includeZero = false, criteriaByGoal = null } = opts;
|
|
58
108
|
|
|
59
109
|
// Weighted term map: body terms at 1, title terms override to 2 (a term in
|
|
60
110
|
// both IS a title term — the stronger signal wins). suggestCriteria's rule.
|
|
@@ -73,10 +123,20 @@ function suggestGoals(goals, draft = {}, opts = {}) {
|
|
|
73
123
|
// by the >=3-char filter, which is fine — 'hall' is the signal.
|
|
74
124
|
...tokenize((g.scope_modules || []).join(' ')),
|
|
75
125
|
]);
|
|
126
|
+
// Criterion terms that the title/modules already cover add nothing — they are
|
|
127
|
+
// scored at the full title weight above, and double-counting would reward a
|
|
128
|
+
// goal for repeating itself.
|
|
129
|
+
const criterionTerms = new Set();
|
|
130
|
+
if (criteriaByGoal) {
|
|
131
|
+
for (const t of tokenize(criteriaByGoal.get(String(g.id)) || '')) {
|
|
132
|
+
if (!goalTerms.has(t)) criterionTerms.add(t);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
76
135
|
let score = 0;
|
|
77
136
|
const matched = [];
|
|
78
137
|
for (const [term, w] of weights) {
|
|
79
138
|
if (goalTerms.has(term)) { score += w; matched.push(term); }
|
|
139
|
+
else if (criterionTerms.has(term)) { score += w * CRITERION_WEIGHT; matched.push(term); }
|
|
80
140
|
}
|
|
81
141
|
return { ...g, score, matched_terms: matched.sort() };
|
|
82
142
|
}).sort((a, b) => (b.score - a.score) || (Number(a.id) - Number(b.id)));
|
|
@@ -133,11 +193,16 @@ async function suggestGoalForCapture({ title = '', body = '' } = {}, deps = {})
|
|
|
133
193
|
&& g.title !== generalGoalTitle(g.version_id));
|
|
134
194
|
if (homes.length === 0) return null;
|
|
135
195
|
|
|
196
|
+
// Criterion prose for the same goals — this is the capture-time half of task
|
|
197
|
+
// 1003859, and it is the half that matters most: suggested_goal_id is written
|
|
198
|
+
// HERE, and it was NULL on every BONGOS-V2 filing.
|
|
199
|
+
const criteriaByGoal = await rankableCriteria(db, homes, captureErr);
|
|
200
|
+
|
|
136
201
|
// versionId is passed null: the catch-alls are already gone, and passing one
|
|
137
202
|
// version's id here would only re-filter for that version. includeZero stays
|
|
138
203
|
// false, so a filing that matches nothing gets NO goal rather than the
|
|
139
204
|
// first one dressed up as a suggestion.
|
|
140
|
-
const [best] = suggestGoals(homes, { title, body }, { versionId: null, limit: 1 });
|
|
205
|
+
const [best] = suggestGoals(homes, { title, body }, { versionId: null, limit: 1, criteriaByGoal });
|
|
141
206
|
if (!best) return null;
|
|
142
207
|
|
|
143
208
|
// suggestGoals normalises to { id, title, scope_modules, … } and drops
|
|
@@ -184,7 +249,8 @@ function goalSuggestHandler({ db, LIMITS }) {
|
|
|
184
249
|
// was an oversight, not a decision.
|
|
185
250
|
try {
|
|
186
251
|
const open = await db.listGoals({ versionId, status: 'open' });
|
|
187
|
-
const
|
|
252
|
+
const criteriaByGoal = await rankableCriteria(db, open || [], proseErr);
|
|
253
|
+
const suggestions = suggestGoals(open, { title, body }, { versionId, limit, criteriaByGoal });
|
|
188
254
|
return res.json({
|
|
189
255
|
version_id: versionId,
|
|
190
256
|
// `kind` is echoed, NOT ranked on. Kind decides how a capture LANDS (the
|
|
@@ -44,12 +44,30 @@ const cascade = require('../cascade');
|
|
|
44
44
|
// leave it. Nothing here can affect the ship: the emitters fire post-commit and
|
|
45
45
|
// already wrap the emit, and `runCascadesForShip` resolves rather than throws.
|
|
46
46
|
let _cascadeListenerOff = null;
|
|
47
|
+
let _ideaCascadeListenerOff = null;
|
|
47
48
|
|
|
48
49
|
function registerSeams() {
|
|
49
50
|
if (!api.hasProvider('lifecycle')) {
|
|
50
51
|
api.registerProvider('lifecycle', lifecycle);
|
|
51
52
|
}
|
|
52
53
|
if (_cascadeListenerOff) _cascadeListenerOff();
|
|
54
|
+
// Torn down with its sibling: the factory runs again every time a test rebuilds
|
|
55
|
+
// the router, and a leaked subscription would fire this cascade twice.
|
|
56
|
+
if (_ideaCascadeListenerOff) _ideaCascadeListenerOff();
|
|
57
|
+
// task 1003856: the second cross-role cascade. Same module, same runner
|
|
58
|
+
// guarantees, different trigger — an idea reaching Full rather than a ship.
|
|
59
|
+
_ideaCascadeListenerOff = api.on('idea.graded.full', (graded) => cascade.runCascadesForIdea(graded, {
|
|
60
|
+
// No getIdea: the ideas module owns that row and sends it whole. Injecting a
|
|
61
|
+
// reader here would mean lifecycle querying another module's table.
|
|
62
|
+
findTaskBySourceRef: (q) => db.findTaskBySourceRef(q),
|
|
63
|
+
createTask: (spec) => db.createTask(spec),
|
|
64
|
+
// INVOKED, like its sibling below. api.logger is a FACTORY (api.logger(ns)),
|
|
65
|
+
// so passing it bare left every log?.info?.() call resolving to undefined and
|
|
66
|
+
// silently doing nothing — the optional chaining that makes the runner safe is
|
|
67
|
+
// also what hid this.
|
|
68
|
+
log: api.logger ? api.logger('lifecycle') : null,
|
|
69
|
+
}));
|
|
70
|
+
|
|
53
71
|
_cascadeListenerOff = api.on('task.shipped', (shipped) => cascade.runCascadesForShip(shipped, {
|
|
54
72
|
getTask: (id) => db.getTask(id),
|
|
55
73
|
findTaskBySourceRef: (ref) => db.findTaskBySourceRef(ref),
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.816",
|
|
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.816",
|
|
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.816",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
package/scripts/gds/capture.js
CHANGED
|
@@ -50,8 +50,12 @@
|
|
|
50
50
|
// the active-claim default below is skipped —
|
|
51
51
|
// an opt-out that still guessed a goal would not
|
|
52
52
|
// be an opt-out.
|
|
53
|
-
// (neither) → default to the ACTIVE CLAIM's goal
|
|
54
|
-
//
|
|
53
|
+
// (neither) → default to the ACTIVE CLAIM's goal — but ONLY
|
|
54
|
+
// when the captured text and the claimed task
|
|
55
|
+
// share a subject word (task 1003859); an
|
|
56
|
+
// unrelated filing says so and stays homeless.
|
|
57
|
+
// Applies if this builder holds a claim and its
|
|
58
|
+
// task has a goal
|
|
55
59
|
// ("capturing mid-work almost always concerns
|
|
56
60
|
// the work" — the interview's own words). With
|
|
57
61
|
// no default to offer, the R07 suggester
|
|
@@ -160,6 +164,29 @@ function sanitizeForTerminal(s) {
|
|
|
160
164
|
return String(s == null ? '' : s).replace(/[\x00-\x1f\x7f]/g, '');
|
|
161
165
|
}
|
|
162
166
|
|
|
167
|
+
// claimShareSubject — does the captured text have ANYTHING in common with the
|
|
168
|
+
// task I happen to be holding? (task 1003859)
|
|
169
|
+
//
|
|
170
|
+
// WHY THE DEFAULT NEEDED A GUARD. The active-claim default is a convenience for
|
|
171
|
+
// the ordinary case: you are working on X and you file something about X. But it
|
|
172
|
+
// fired unconditionally, so filing an UNRELATED bug mid-session minted it into
|
|
173
|
+
// the claim's goal — this very bug was filed from a session holding a security
|
|
174
|
+
// task and landed in Working area 10 (Security), which has nothing to do with it,
|
|
175
|
+
// and had to be moved by hand. ADR 0234's owner-interview decision is that a
|
|
176
|
+
// guess must not mint work into a goal, and an inherited goal with no subject in
|
|
177
|
+
// common is exactly a guess.
|
|
178
|
+
//
|
|
179
|
+
// Reuses criterion-suggest's tokenize — the same notion of 'these two texts are
|
|
180
|
+
// about the same thing' the goal suggester ranks with, so the guard and the
|
|
181
|
+
// suggester cannot disagree about what a subject word is.
|
|
182
|
+
const { tokenize } = require('../../modules/lifecycle/criterion-suggest.js');
|
|
183
|
+
|
|
184
|
+
function claimShareSubject(capturedText, claimTitle) {
|
|
185
|
+
const theirs = new Set(tokenize(claimTitle));
|
|
186
|
+
if (theirs.size === 0) return true; // nothing to disagree with — keep the old behaviour
|
|
187
|
+
return tokenize(capturedText).some((t) => theirs.has(t));
|
|
188
|
+
}
|
|
189
|
+
|
|
163
190
|
// resolveGoalForCapture — the WHOLE of --goal resolution for one capture, in the
|
|
164
191
|
// interview's own precedence: an explicit id wins outright; 'none' is a hard
|
|
165
192
|
// opt-out (no default, no hint); otherwise the active-claim default runs, and
|
|
@@ -168,26 +195,34 @@ function sanitizeForTerminal(s) {
|
|
|
168
195
|
// is testable without capturing stdout. Never throws (both collaborators above
|
|
169
196
|
// already degrade to null on their own).
|
|
170
197
|
async function resolveGoalForCapture({ goalArg, version, title, kind, client = cliClient } = {}) {
|
|
198
|
+
const claimLines = [];
|
|
171
199
|
const parsed = parseGoalArg(goalArg);
|
|
172
200
|
if (parsed.kind === 'id') return { goalId: parsed.id, lines: [] };
|
|
173
201
|
if (parsed.kind === 'none') return { goalId: null, lines: [] };
|
|
174
202
|
|
|
175
203
|
const defaulted = await resolveActiveClaimGoal({ client });
|
|
176
204
|
if (defaulted) {
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
205
|
+
// Only inherit when the two are actually about the same thing. When they are
|
|
206
|
+
// not, say so and fall through to the suggester — which ranks on what was
|
|
207
|
+
// WRITTEN rather than on what happens to be open — instead of filing silently
|
|
208
|
+
// into the wrong goal.
|
|
209
|
+
if (claimShareSubject(title, defaulted.taskTitle)) {
|
|
210
|
+
return {
|
|
211
|
+
goalId: defaulted.goalId,
|
|
212
|
+
lines: [`↪ defaulting --goal to ${defaulted.goalId} from your active claim on task #${defaulted.taskId} ("${sanitizeForTerminal(defaulted.taskTitle)}") — override with --goal <id>, or --goal none to file homeless.`],
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
claimLines.push(`↪ NOT defaulting to your active claim's goal: task #${defaulted.taskId} ("${sanitizeForTerminal(defaulted.taskTitle)}") shares no subject with what you filed — pass --goal ${defaulted.goalId} if it really belongs there.`);
|
|
181
216
|
}
|
|
182
217
|
|
|
183
218
|
const hint = await fetchGoalSuggestion({ client, version, title, kind });
|
|
184
219
|
if (hint) {
|
|
185
220
|
return {
|
|
186
221
|
goalId: null,
|
|
187
|
-
lines: [`💡 possible home: goal ${hint.id} "${sanitizeForTerminal(hint.title)}" (score ${hint.score}) — re-run with --goal ${hint.id} (or --goal none to stay homeless on purpose).`],
|
|
222
|
+
lines: [...claimLines, `💡 possible home: goal ${hint.id} "${sanitizeForTerminal(hint.title)}" (score ${hint.score}) — re-run with --goal ${hint.id} (or --goal none to stay homeless on purpose).`],
|
|
188
223
|
};
|
|
189
224
|
}
|
|
190
|
-
return { goalId: null, lines:
|
|
225
|
+
return { goalId: null, lines: claimLines };
|
|
191
226
|
}
|
|
192
227
|
|
|
193
228
|
// DEFAULT IS NO, and only an explicit yes counts. Extracted so the done-when
|
package/src/module-api.js
CHANGED
|
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
71
71
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
72
72
|
// the entry to that file. Look for a version's history there, not here.
|
|
73
73
|
// ---------------------------------------------------------------------------
|
|
74
|
-
const CORE_VERSION = '1.19.
|
|
74
|
+
const CORE_VERSION = '1.19.816'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
75
75
|
|
|
76
76
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
77
77
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -11,6 +11,9 @@
|
|
|
11
11
|
//
|
|
12
12
|
// Run: node tests/agents_validate.mjs
|
|
13
13
|
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import { fileURLToPath } from 'node:url';
|
|
14
17
|
import { strict as assert } from 'node:assert';
|
|
15
18
|
import { createRequire } from 'node:module';
|
|
16
19
|
|
|
@@ -73,9 +76,37 @@ await test('a failed validation returns NO value — nothing downstream can inse
|
|
|
73
76
|
|
|
74
77
|
// --- THE EVENT CATALOG — the fail-closed rule -------------------------------
|
|
75
78
|
|
|
76
|
-
await test('EVENT_CATALOG is
|
|
77
|
-
assert.deepEqual(EVENT_CATALOG, ['task.shipped']);
|
|
79
|
+
await test('EVENT_CATALOG is frozen, and every entry is REALLY dispatched', () => {
|
|
78
80
|
assert.equal(Object.isFrozen(EVENT_CATALOG), true, 'a caller must not be able to widen the catalog at runtime');
|
|
81
|
+
assert.ok(EVENT_CATALOG.includes('task.shipped'), 'the original wired event stays wired');
|
|
82
|
+
|
|
83
|
+
// The catalog's own comment is the rule: widening it "must land together with
|
|
84
|
+
// something that actually dispatches the new event". Pinned as that rule rather
|
|
85
|
+
// than as a frozen list, because a list cannot tell a legitimate widening from
|
|
86
|
+
// one that ships an entry nothing emits — and the second produces an agent that
|
|
87
|
+
// looks armed and never fires, which is the failure the catalog exists to stop.
|
|
88
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
89
|
+
const files = [];
|
|
90
|
+
const walk = (dir) => {
|
|
91
|
+
let ents = [];
|
|
92
|
+
try { ents = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
93
|
+
for (const e of ents) {
|
|
94
|
+
const full = path.join(dir, e.name);
|
|
95
|
+
if (e.isDirectory()) { if (e.name !== 'node_modules') walk(full); continue; }
|
|
96
|
+
if (e.name.endsWith('.js')) files.push(full);
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
for (const d of ['src', 'modules']) walk(path.join(ROOT, d));
|
|
100
|
+
const tree = files.map((f) => fs.readFileSync(f, 'utf8')).join('\n');
|
|
101
|
+
|
|
102
|
+
for (const event of EVENT_CATALOG) {
|
|
103
|
+
// Any quote style, emit or emitAsync. A plain substring scan rather than a
|
|
104
|
+
// built regex: the thing being asserted is "somebody emits this string".
|
|
105
|
+
const dispatched = ['emit(', 'emitAsync('].some((call) =>
|
|
106
|
+
[String.fromCharCode(39), '"', String.fromCharCode(96)].some((q) => tree.includes(call + q + event + q)));
|
|
107
|
+
assert.ok(dispatched,
|
|
108
|
+
`"${event}" is in the catalog but nothing in src/ or modules/ emits it — an agent armed on it would never fire`);
|
|
109
|
+
}
|
|
79
110
|
});
|
|
80
111
|
|
|
81
112
|
await test('an UNWIRED event name is REFUSED, never silently accepted', () => {
|
|
@@ -181,7 +181,11 @@ await test('no --goal: the active-claim default fires and names how to opt out',
|
|
|
181
181
|
me: { ok: true, data: { active_claims: [{ task_id: 55, title: 'Fix the thing' }] } },
|
|
182
182
|
task: { ok: true, data: { task: { id: 55, goal_id: 1000071, title: 'Fix the thing' } } },
|
|
183
183
|
});
|
|
184
|
-
|
|
184
|
+
// The captured text shares 'thing' with the claim — the ordinary case the
|
|
185
|
+
// default exists for. (It used to be the bare title 't'; since task 1003859 the
|
|
186
|
+
// default only fires on a shared subject word, so a placeholder no longer
|
|
187
|
+
// exercises this path.)
|
|
188
|
+
const r = await capture.resolveGoalForCapture({ goalArg: null, version: 'BV1', title: 'the thing is broken', client });
|
|
185
189
|
assert.equal(r.goalId, 1000071);
|
|
186
190
|
assert.equal(r.lines.length, 1);
|
|
187
191
|
assert.match(r.lines[0], /1000071/);
|
|
@@ -239,4 +243,64 @@ await test('WIRING: a routing refusal is surfaced through the existing verbatim
|
|
|
239
243
|
assert.match(CAPTURE_SRC, /console\.error\(formatCaptureFailure\(r\.status, r\.data\)\);/);
|
|
240
244
|
});
|
|
241
245
|
|
|
246
|
+
// ---------------------------------------------------------------------------
|
|
247
|
+
// the active-claim default must not mint UNRELATED work into the claim's goal
|
|
248
|
+
// (task 1003859)
|
|
249
|
+
// ---------------------------------------------------------------------------
|
|
250
|
+
//
|
|
251
|
+
// The default fired unconditionally, so filing an unrelated bug mid-session
|
|
252
|
+
// routed it into whatever goal you happened to be holding — task 1003859 itself
|
|
253
|
+
// was filed from a security claim and landed in Working area 10, and had to be
|
|
254
|
+
// moved by hand. ADR 0234: a guess must not mint work into a goal.
|
|
255
|
+
|
|
256
|
+
await test('no --goal, claim shares NO subject with the filing: does not inherit, and says why', async () => {
|
|
257
|
+
const { client, calls } = stubClient({
|
|
258
|
+
me: { ok: true, data: { active_claims: [{ task_id: 55, title: 'Harden the OAuth callback against replay' }] } },
|
|
259
|
+
task: { ok: true, data: { task: { id: 55, goal_id: 1000094, title: 'Harden the OAuth callback against replay' } } },
|
|
260
|
+
suggestions: { ok: true, data: { suggestions: [] } },
|
|
261
|
+
});
|
|
262
|
+
const r = await capture.resolveGoalForCapture({
|
|
263
|
+
goalArg: null, version: 'BV1', title: 'The goal suggester returns zero suggestions for every idea', client,
|
|
264
|
+
});
|
|
265
|
+
assert.equal(r.goalId, null, 'an unrelated claim must not decide where this work lives');
|
|
266
|
+
assert.equal(r.lines.length, 1, 'staying homeless silently would be the same bug with no evidence');
|
|
267
|
+
assert.match(r.lines[0], /NOT defaulting/);
|
|
268
|
+
assert.match(r.lines[0], /1000094/, 'it must name the goal it declined, so --goal can override it');
|
|
269
|
+
assert.equal(calls.suggestions.length, 1, 'with no default, the suggester still gets its turn');
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
await test('a declined default still yields to a real suggestion, keeping both lines', async () => {
|
|
273
|
+
const { client } = stubClient({
|
|
274
|
+
me: { ok: true, data: { active_claims: [{ task_id: 55, title: 'Harden the OAuth callback against replay' }] } },
|
|
275
|
+
task: { ok: true, data: { task: { id: 55, goal_id: 1000094, title: 'Harden the OAuth callback against replay' } } },
|
|
276
|
+
suggestions: { ok: true, data: { suggestions: [{ id: 1000091, title: 'Working area 7', score: 3 }] } },
|
|
277
|
+
});
|
|
278
|
+
const r = await capture.resolveGoalForCapture({
|
|
279
|
+
goalArg: null, version: 'BV1', title: 'The goal suggester returns zero suggestions', client,
|
|
280
|
+
});
|
|
281
|
+
assert.equal(r.goalId, null);
|
|
282
|
+
assert.equal(r.lines.length, 2, 'the refusal and the hint are different facts — both are printed');
|
|
283
|
+
assert.match(r.lines[0], /NOT defaulting/);
|
|
284
|
+
assert.match(r.lines[1], /1000091/);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
await test('--goal N still wins outright over the guard (it is not a veto)', async () => {
|
|
288
|
+
const { client, calls } = stubClient({
|
|
289
|
+
me: { ok: true, data: { active_claims: [{ task_id: 55, title: 'Something else entirely' }] } },
|
|
290
|
+
task: { ok: true, data: { task: { id: 55, goal_id: 1000094 } } },
|
|
291
|
+
});
|
|
292
|
+
const r = await capture.resolveGoalForCapture({ goalArg: '1000094', version: 'BV1', title: 'unrelated words here', client });
|
|
293
|
+
assert.deepEqual(r, { goalId: 1000094, lines: [] });
|
|
294
|
+
assert.equal(calls.me, 0);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
await test('a claim title with no subject words at all still inherits — the guard only fires on real disagreement', async () => {
|
|
298
|
+
const { client } = stubClient({
|
|
299
|
+
me: { ok: true, data: { active_claims: [{ task_id: 55, title: 'a' }] } },
|
|
300
|
+
task: { ok: true, data: { task: { id: 55, goal_id: 1000071, title: 'a' } } },
|
|
301
|
+
});
|
|
302
|
+
const r = await capture.resolveGoalForCapture({ goalArg: null, version: 'BV1', title: 'anything at all', client });
|
|
303
|
+
assert.equal(r.goalId, 1000071, 'nothing to compare against is not evidence of a mismatch');
|
|
304
|
+
});
|
|
305
|
+
|
|
242
306
|
summary();
|