@bongos/core 1.19.591 → 1.19.593
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 +72 -42
- package/.claude/skills/planning-session/SKILL.md +2 -2
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +3 -1
- package/clients/bongos-client/index.cjs +3 -1
- package/clients/bongos-client/index.d.ts +6 -2
- package/clients/bongos-client/index.mjs +3 -1
- package/docs/adr/0157-archon-is-rank-and-identity-only.md +2 -0
- package/docs/api/openapi.json +103 -7
- package/docs/api-reference.md +4 -3
- package/docs/copy-inventory.md +135 -116
- package/docs/copy-registry.json +319 -138
- package/docs/module-api-changelog.md +4 -0
- package/migrations/core_233_versions_one_planning_idx.sql +47 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +3 -1
- package/modules/government/catalog.js +20 -11
- package/modules/government/migrations/government_012_version_create_archon.sql +45 -0
- package/modules/hall-ui/public/goals-render.js +182 -6
- package/modules/hall-ui/public/goals.css +51 -0
- package/modules/hall-ui/public/roadmap.css +5 -0
- package/modules/hall-ui/public/roadmap.js +25 -0
- package/modules/hall-ui/public/task-detail.js +22 -11
- package/modules/lifecycle/db-goals.js +34 -2
- package/modules/lifecycle/db-versions.js +195 -3
- package/modules/lifecycle/db.js +4 -1
- package/modules/lifecycle/routes/goals.js +15 -1
- package/modules/lifecycle/routes/version-route-authz.js +171 -1
- package/modules/lifecycle/routes/versions.js +130 -10
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/triage.js +60 -26
- package/src/module-api.js +1 -1
- package/tests/goal_archive_hall.mjs +198 -0
- package/tests/government_require_permission.mjs +7 -3
- package/tests/government_seed.mjs +101 -9
- package/tests/one_planning_version.mjs +25 -8
- package/tests/promote_goal_id.mjs +85 -30
- package/tests/publish_reconciler.mjs +35 -5
- package/tests/task_detail_ui.mjs +36 -9
- package/tests/version_build_slot.mjs +157 -0
- package/tests/version_close_route.mjs +261 -0
- package/tests/version_override_visibility.mjs +176 -0
|
@@ -23,7 +23,7 @@ const auth = api;
|
|
|
23
23
|
const db = require('../db');
|
|
24
24
|
const versionClose = require('../version-close');
|
|
25
25
|
const doneWhen = require('../done-when');
|
|
26
|
-
const { authorizeVersionCreate } = require('./version-route-authz.js');
|
|
26
|
+
const { authorizeVersionCreate, authorizeVersionBuild, planVersionClose, rollForwardNeedsPlanningVersion } = require('./version-route-authz.js');
|
|
27
27
|
const { asyncHandler, validateOrRespond } = api;
|
|
28
28
|
|
|
29
29
|
// versions.status CHECK (migration 003). A new version starts 'planning' or
|
|
@@ -128,15 +128,11 @@ module.exports = function buildVersionsRouter() {
|
|
|
128
128
|
// sibling of R03's authorizeGoalCreate — the route gathers the facts and
|
|
129
129
|
// renders the verdict, it does not carry the rule.
|
|
130
130
|
//
|
|
131
|
-
// RACE,
|
|
132
|
-
//
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
//
|
|
136
|
-
// migration this task did not reserve a number for; task 1003606 (R19) adds
|
|
137
|
-
// the second-BUILDING-version rule of the same shape and is the natural place
|
|
138
|
-
// to add the index for both. Until then the exposure is bounded by the route
|
|
139
|
-
// being Archon-gated and used a few times a year — small, not zero.
|
|
131
|
+
// THE RACE IS CLOSED (BV1.R19, task 1003606, migration core_233): this is
|
|
132
|
+
// still check-then-insert, but `versions_one_planning_idx` now backs it, so a
|
|
133
|
+
// race-loser gets a 23505 that the catch below translates into this same
|
|
134
|
+
// refusal. R04 shipped this check documenting that nothing caught it; that is
|
|
135
|
+
// no longer true for the planning slot.
|
|
140
136
|
const versionDecision = authorizeVersionCreate({
|
|
141
137
|
status,
|
|
142
138
|
planning: status === 'planning' ? await db.versionsWithStatus('planning') : [],
|
|
@@ -144,6 +140,27 @@ module.exports = function buildVersionsRouter() {
|
|
|
144
140
|
if (!versionDecision.ok) {
|
|
145
141
|
return res.status(versionDecision.status).json(versionDecision.body);
|
|
146
142
|
}
|
|
143
|
+
|
|
144
|
+
// BV1.R19 (task 1003606, goal 1000086, ADR 0250 D5, ADR 0263 §9): the other
|
|
145
|
+
// half of the same rule. Exactly one version builds at a time — with two,
|
|
146
|
+
// "the current version" has no meaning and R03's goal-set gate has two
|
|
147
|
+
// answers depending on which row `currentBuildingVersionId`'s LIMIT 1 picked.
|
|
148
|
+
//
|
|
149
|
+
// NOT BACKED BY AN INDEX YET, and that is a data problem rather than an
|
|
150
|
+
// oversight: BONGOS-V1 and CB-V1 are both `building` on the live instance
|
|
151
|
+
// today, so `versions_one_building_idx` cannot be created until task 1003614
|
|
152
|
+
// (R27) folds them. core_233's header records why shipping it early — either
|
|
153
|
+
// unconditionally, which wedges the deploy, or skipped-with-a-NOTICE, which
|
|
154
|
+
// marks the migration applied and never arms — is worse than shipping it with
|
|
155
|
+
// the cutover. Until then this check-then-insert carries the rule alone, with
|
|
156
|
+
// the same bounded exposure R04 accepted: an Archon-gated route used a few
|
|
157
|
+
// times a year.
|
|
158
|
+
const buildDecision = authorizeVersionBuild({
|
|
159
|
+
building: status === 'building' ? await db.versionsWithStatus('building') : [],
|
|
160
|
+
});
|
|
161
|
+
if (!buildDecision.ok) {
|
|
162
|
+
return res.status(buildDecision.status).json(buildDecision.body);
|
|
163
|
+
}
|
|
147
164
|
// Normalize criteria: each { criterion_id, criterion_md } (+ optional sort_order).
|
|
148
165
|
const rawCriteria = Array.isArray(body.criteria) ? body.criteria : [];
|
|
149
166
|
const criteria = [];
|
|
@@ -176,6 +193,31 @@ module.exports = function buildVersionsRouter() {
|
|
|
176
193
|
// returns, instead of letting asyncHandler surface a 500 (hacker dispatch,
|
|
177
194
|
// task 1204).
|
|
178
195
|
if (err && err.code === '23505') {
|
|
196
|
+
// TWO unique constraints can raise here now, and they mean different
|
|
197
|
+
// things (BV1.R19, migration core_233). `versions_one_planning_idx` is
|
|
198
|
+
// the race-loser of the one-planning-version rule — the pre-check above
|
|
199
|
+
// passed because the winner had not committed yet — and must render as
|
|
200
|
+
// that rule's refusal, not as "this id is taken", which would send the
|
|
201
|
+
// caller off to rename a version that was never the problem. Matched on
|
|
202
|
+
// the constraint name rather than the message text, which is localised.
|
|
203
|
+
if (String(err.constraint || '') === 'versions_one_planning_idx') {
|
|
204
|
+
// Re-read rather than reuse the pre-check's rows: the winner has
|
|
205
|
+
// committed by now, so this read is what names it.
|
|
206
|
+
//
|
|
207
|
+
// The refusal is still rendered BY THE GATE, not written here — this
|
|
208
|
+
// route must never carry a second copy of the rule's wording, which is
|
|
209
|
+
// what tests/one_planning_version.mjs pins. The index only raises when
|
|
210
|
+
// a planning row exists, so the re-read is non-empty in every real
|
|
211
|
+
// case; the fallback row covers the one interleaving where the winner
|
|
212
|
+
// was deleted between the raise and this read, and exists solely so the
|
|
213
|
+
// gate cannot be handed an empty list and return `ok` with no body.
|
|
214
|
+
const planningNow = await db.versionsWithStatus('planning');
|
|
215
|
+
const raced = authorizeVersionCreate({
|
|
216
|
+
status: 'planning',
|
|
217
|
+
planning: planningNow.length ? planningNow : [{ id: '(a concurrent create)', name: '' }],
|
|
218
|
+
});
|
|
219
|
+
return res.status(raced.status).json(raced.body);
|
|
220
|
+
}
|
|
179
221
|
return res.fail('version_exists', { status: 409, message: `Version '${id}' already exists.` });
|
|
180
222
|
}
|
|
181
223
|
throw err;
|
|
@@ -183,5 +225,83 @@ module.exports = function buildVersionsRouter() {
|
|
|
183
225
|
res.status(201).json({ version, criteria: created });
|
|
184
226
|
}, { errorCode: 'create_version_failed' }));
|
|
185
227
|
|
|
228
|
+
|
|
229
|
+
// POST /versions/:id/close — the move a version has never had (BV1.R12, task
|
|
230
|
+
// 1003599, goal 1000086, ADR 0250 D5, ADR 0263 §5).
|
|
231
|
+
//
|
|
232
|
+
// The hierarchy had a bottom and no top: tasks close, criteria close, goals
|
|
233
|
+
// close, and a version only ever moved by hand-run SQL on the droplet —
|
|
234
|
+
// `scripts/gds/version-close.js` still PRINTS that the API cannot do this (task
|
|
235
|
+
// 1003610, R23, points it here). A thing with no closing move does not close,
|
|
236
|
+
// which is how two versions ended up building at once.
|
|
237
|
+
//
|
|
238
|
+
// Archon-only, behind the `version.close` permission that has existed at that
|
|
239
|
+
// floor since BV1.R105 and that, until now, NOTHING referenced.
|
|
240
|
+
//
|
|
241
|
+
// THE TWO-STEP IS R14's ARCHIVE, in spirit and in shape: call it bare and a
|
|
242
|
+
// version holding open goals refuses with the GOALS ATTACHED; re-submit with a
|
|
243
|
+
// total `dispositions` map and it closes. Agents already know that interaction,
|
|
244
|
+
// and a second idiom for the same thing is how two surfaces drift apart.
|
|
245
|
+
//
|
|
246
|
+
// Auto-close (task 1003603, R16) reaches `db.closeVersion` with an EMPTY plan
|
|
247
|
+
// and never touches this route: by construction it fires only when the last
|
|
248
|
+
// non-maintenance goal achieved, so nothing is left to disposition (ADR 0263 §3).
|
|
249
|
+
// rank: archon — the disposition that finalises a version's scope-truth.
|
|
250
|
+
router.post('/versions/:id/close', auth.requireBuilder, auth.requirePermission('version.close'), asyncHandler('POST /versions/:id/close', async (req, res) => {
|
|
251
|
+
// ADR 0118 / R12: declare the body, so a typo'd `disposition` (singular) is
|
|
252
|
+
// REJECTED rather than read as "none given" — which would take the refusal
|
|
253
|
+
// branch and tell a caller who sent a complete map that the version holds open
|
|
254
|
+
// goals. Contents are validated by planVersionClose, which names every
|
|
255
|
+
// malformed entry.
|
|
256
|
+
if (validateOrRespond(req, res, {
|
|
257
|
+
reason: { type: 'string', maxLength: 2000 },
|
|
258
|
+
dispositions: { type: 'object' },
|
|
259
|
+
})) return;
|
|
260
|
+
const versionId = String(req.params.id || '').trim();
|
|
261
|
+
if (!versionId) return res.fail('version_id_required', 400);
|
|
262
|
+
|
|
263
|
+
const version = await db.getVersion(versionId);
|
|
264
|
+
const reason = typeof req.body?.reason === 'string' ? req.body.reason.slice(0, 2000) : '';
|
|
265
|
+
// The maintenance goal is filtered out by the READ (ADR 0263 §7): it is exempt
|
|
266
|
+
// from the close count and always carries forward, so it is never the caller's
|
|
267
|
+
// decision and must not appear in a refusal asking them to make one.
|
|
268
|
+
const openGoals = version ? await db.openNonMaintenanceGoals(versionId) : [];
|
|
269
|
+
|
|
270
|
+
const plan = planVersionClose({ version, openGoals, dispositions: req.body?.dispositions ?? null, reason });
|
|
271
|
+
if (!plan.ok) return res.status(plan.status).json(plan.body);
|
|
272
|
+
|
|
273
|
+
// A roll_forward needs a planning version to roll INTO, and its absence is a
|
|
274
|
+
// REFUSAL rather than an auto-create: cutting the successor is a scope
|
|
275
|
+
// decision with its own criteria, and doing it implicitly inside a close is
|
|
276
|
+
// the "fake hotfix version" ADR 0250 §3 built the override counter to prevent.
|
|
277
|
+
const needsPlanning = rollForwardNeedsPlanningVersion({
|
|
278
|
+
plan: plan.plan,
|
|
279
|
+
planning: plan.plan.some((p) => p.verb === 'roll_forward') ? await db.versionsWithStatus('planning') : [],
|
|
280
|
+
});
|
|
281
|
+
if (needsPlanning) return res.status(needsPlanning.status).json(needsPlanning.body);
|
|
282
|
+
|
|
283
|
+
try {
|
|
284
|
+
const out = await db.closeVersion({ versionId, plan: plan.plan, reason });
|
|
285
|
+
return res.json({ ok: true, version: out.version, applied: out.applied });
|
|
286
|
+
} catch (err) {
|
|
287
|
+
if (err && err.code === 'VERSION_NOT_FOUND') return res.fail('version_not_found', 404);
|
|
288
|
+
if (err && err.code === 'VERSION_NOT_BUILDING') {
|
|
289
|
+
return res.fail('version_not_building', {
|
|
290
|
+
status: 409,
|
|
291
|
+
message: `Version '${versionId}' changed to '${err.versionStatus}' while the close was running — nothing was applied.`,
|
|
292
|
+
details: { status: err.versionStatus },
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
if (err && err.code === 'CLOSE_INCOMPLETE_AFTER_APPLY') {
|
|
296
|
+
return res.fail('version_holds_open_goals', {
|
|
297
|
+
status: 409,
|
|
298
|
+
message: `Version '${versionId}' still held ${err.remaining} open goal(s) after the dispositions were applied — nothing was changed. Re-read and retry.`,
|
|
299
|
+
details: { remaining: err.remaining },
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
throw err;
|
|
303
|
+
}
|
|
304
|
+
}, { errorCode: 'close_version_failed' }));
|
|
305
|
+
|
|
186
306
|
return router;
|
|
187
307
|
};
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.593",
|
|
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.593",
|
|
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.593",
|
|
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/triage.js
CHANGED
|
@@ -220,15 +220,37 @@ async function resolveGoalDefault(idea, versionId, { client = cliClient } = {})
|
|
|
220
220
|
// default — declining has to be said ('-' or 'none'), because silently dropping a
|
|
221
221
|
// real default on a bare Enter is how work ends up in the general bucket by
|
|
222
222
|
// accident. Declining is legal: the server then applies the catch-all itself.
|
|
223
|
+
// applyGoalAnswer — read the operator's goal answer at the triage prompt.
|
|
224
|
+
//
|
|
225
|
+
// BV1.R22 (task 1003609, goal 1000086): THE GENERAL BUCKET IS GONE. Every branch
|
|
226
|
+
// that used to resolve to `goalId: null` did so because a goal-less create fell
|
|
227
|
+
// into the version's catch-all "<version> — general" goal. R11 (task 1003598)
|
|
228
|
+
// deleted that fallback and made `goal_id` required, so `null` is no longer an
|
|
229
|
+
// opt-out — it is a `400 goal_id_required` from the server, arriving after the
|
|
230
|
+
// operator has typed a title, a description and a priority, and losing all of it.
|
|
231
|
+
//
|
|
232
|
+
// So the two escapes become refusals, and they refuse HERE, before the round trip:
|
|
233
|
+
// '-' / 'none' — was "file it in the general bucket"; there is no bucket.
|
|
234
|
+
// a junk answer — was silently downgraded to the bucket with a warning, which
|
|
235
|
+
// is the worse of the two: a typo'd goal id read as a decision.
|
|
236
|
+
//
|
|
237
|
+
// `ok: false` means re-ask. The caller loops rather than aborting the whole
|
|
238
|
+
// triage run, because the operator's other answers are still good.
|
|
223
239
|
function applyGoalAnswer(defaultGoalId, rawAnswer) {
|
|
224
240
|
const answer = String(rawAnswer == null ? '' : rawAnswer).trim();
|
|
225
|
-
if (answer === '')
|
|
226
|
-
|
|
241
|
+
if (answer === '') {
|
|
242
|
+
return defaultGoalId != null
|
|
243
|
+
? { ok: true, goalId: defaultGoalId, warn: null }
|
|
244
|
+
: { ok: false, goalId: null, warn: 'a goal is required — every task belongs to one. Enter a goal id (GET /goals?version=<id> lists them), or "s" to skip this idea.' };
|
|
245
|
+
}
|
|
246
|
+
if (answer === '-' || answer.toLowerCase() === 'none') {
|
|
247
|
+
return { ok: false, goalId: null, warn: 'the version\'s "general" catch-all no longer exists (task 1003598) — a task must name a real goal. Enter a goal id, or "s" to skip this idea.' };
|
|
248
|
+
}
|
|
227
249
|
const parsed = Number(answer);
|
|
228
250
|
if (!Number.isInteger(parsed) || parsed <= 0) {
|
|
229
|
-
return { goalId: null, warn: "not a goal id
|
|
251
|
+
return { ok: false, goalId: null, warn: `"${answer}" is not a goal id. Enter a number, or "s" to skip this idea.` };
|
|
230
252
|
}
|
|
231
|
-
return { goalId: parsed, warn: null };
|
|
253
|
+
return { ok: true, goalId: parsed, warn: null };
|
|
232
254
|
}
|
|
233
255
|
|
|
234
256
|
async function createTask({ versionId, title, description, priority, sourceRef, goalId = null }) {
|
|
@@ -239,13 +261,13 @@ async function createTask({ versionId, title, description, priority, sourceRef,
|
|
|
239
261
|
// make, and the server classifier still runs. Corrected rather than deleted so
|
|
240
262
|
// the next reader does not re-derive it.)
|
|
241
263
|
//
|
|
242
|
-
// task 1003071 (R13)
|
|
243
|
-
//
|
|
244
|
-
// goal-less create into
|
|
245
|
-
//
|
|
246
|
-
//
|
|
247
|
-
//
|
|
248
|
-
//
|
|
264
|
+
// task 1003071 (R13) named the goal here; BV1.R22 (task 1003609) makes it
|
|
265
|
+
// MANDATORY. R11 (task 1003598) deleted the "<version> — general" catch-all that
|
|
266
|
+
// a goal-less create used to fall into, so omitting goal_id is now a server
|
|
267
|
+
// `400 goal_id_required` rather than a quiet mis-filing. Reaching this function
|
|
268
|
+
// without one is a bug in the caller, and it throws rather than posting: the
|
|
269
|
+
// prompt above re-asks until it has a real id, so the only way here with null is
|
|
270
|
+
// a new call site that forgot — which should fail at its author, not in prod.
|
|
249
271
|
const body = {
|
|
250
272
|
version_id: versionId,
|
|
251
273
|
title,
|
|
@@ -255,7 +277,10 @@ async function createTask({ versionId, title, description, priority, sourceRef,
|
|
|
255
277
|
source: 'idea_inbox',
|
|
256
278
|
source_ref: sourceRef ?? null,
|
|
257
279
|
};
|
|
258
|
-
if (goalId
|
|
280
|
+
if (goalId == null) {
|
|
281
|
+
throw new Error('createTask: goal_id is required — every task belongs to a goal (task 1003598 deleted the catch-all fallback).');
|
|
282
|
+
}
|
|
283
|
+
body.goal_id = Number(goalId);
|
|
259
284
|
const api = await cliClient();
|
|
260
285
|
const r = await api.tasks.postTasks({ body });
|
|
261
286
|
if (!r.ok) {
|
|
@@ -612,21 +637,30 @@ async function main() {
|
|
|
612
637
|
const title = titleAns || idea.title;
|
|
613
638
|
|
|
614
639
|
// task 1003071 (R13): a triaged promotion NAMES its goal. The default
|
|
615
|
-
// comes from the stored hint, else the R07 suggester
|
|
616
|
-
//
|
|
617
|
-
//
|
|
618
|
-
//
|
|
619
|
-
//
|
|
640
|
+
// comes from the stored hint, else the R07 suggester. The EFFECTIVE
|
|
641
|
+
// title, not idea.title: the suggester matches on title terms, and the
|
|
642
|
+
// operator may just have retitled this.
|
|
643
|
+
//
|
|
644
|
+
// BV1.R22 (task 1003609): the opt-out is gone, so this RE-ASKS instead of
|
|
645
|
+
// falling through. Skipping is still available and is now spelled 's' —
|
|
646
|
+
// it leaves the idea open for the next pass rather than filing it
|
|
647
|
+
// somewhere it does not belong, which is what the general bucket was.
|
|
620
648
|
const gd = await resolveGoalDefault({ ...idea, title }, versionId);
|
|
621
649
|
if (gd.source === 'suggester') console.log(` suggested goal: ${gd.label}`);
|
|
622
|
-
if (gd.source === 'unavailable') console.log(' (goal suggester unreachable — enter a goal id
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
650
|
+
if (gd.source === 'unavailable') console.log(' (goal suggester unreachable — enter a goal id yourself; GET /goals?version=<id> lists them)');
|
|
651
|
+
let goalId = null;
|
|
652
|
+
let skipIdea = false;
|
|
653
|
+
for (;;) {
|
|
654
|
+
const goalPrompt = gd.goalId != null
|
|
655
|
+
? ` goal [${gd.goalId}] ("s" to skip this idea): `
|
|
656
|
+
: ' goal id ("s" to skip this idea): ';
|
|
657
|
+
const goalAns = (await prompt(rl, goalPrompt)).trim();
|
|
658
|
+
if (goalAns.toLowerCase() === 's') { skipIdea = true; break; }
|
|
659
|
+
const picked = applyGoalAnswer(gd.goalId, goalAns);
|
|
660
|
+
if (picked.ok) { goalId = picked.goalId; break; }
|
|
661
|
+
console.log(` ! ${picked.warn}`);
|
|
662
|
+
}
|
|
663
|
+
if (skipIdea) { console.log(' – skipped (still open in the inbox)'); summary.skipped++; continue; }
|
|
630
664
|
|
|
631
665
|
try {
|
|
632
666
|
const task = await createTask({
|
|
@@ -644,7 +678,7 @@ async function main() {
|
|
|
644
678
|
});
|
|
645
679
|
summary.promoted++;
|
|
646
680
|
promotedIds.push({ ideaId: idea.id, taskId: task.id });
|
|
647
|
-
console.log(` ✓ promoted #${idea.id} → task #${task.id} (${versionId}, P${priority}
|
|
681
|
+
console.log(` ✓ promoted #${idea.id} → task #${task.id} (${versionId}, P${priority}, goal ${goalId})`);
|
|
648
682
|
} catch (err) {
|
|
649
683
|
console.error(` ! ${err.message}`);
|
|
650
684
|
}
|
package/src/module-api.js
CHANGED
|
@@ -55,7 +55,7 @@ const { buildInfo } = require('./build-info');
|
|
|
55
55
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
56
56
|
// the entry to that file. Look for a version's history there, not here.
|
|
57
57
|
// ---------------------------------------------------------------------------
|
|
58
|
-
const CORE_VERSION = '1.19.
|
|
58
|
+
const CORE_VERSION = '1.19.593'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
59
59
|
|
|
60
60
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
61
61
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
// tests/goal_archive_hall.mjs — the goal page tells the truth about what is
|
|
2
|
+
// holding a goal open, and walks the archive two-step without curl
|
|
3
|
+
// (BV1.R24, task 1003611, goal 1000086, ADR 0250 D2/D3).
|
|
4
|
+
//
|
|
5
|
+
// TWO THINGS THIS PAGE COULD NOT SAY BEFORE.
|
|
6
|
+
//
|
|
7
|
+
// 1. WHY A GOAL IS STILL OPEN. Until R08/R09 (tasks 1003595, 1003596) a goal
|
|
8
|
+
// closed on its criteria alone, so "N of M criteria satisfied" was the whole
|
|
9
|
+
// answer. ADR 0250 D2 added "AND no task in it is non-terminal" — and a task
|
|
10
|
+
// linked to NO criterion appears nowhere in that rollup. A builder could read
|
|
11
|
+
// 5-of-5 satisfied, watch the goal stay open, and find nothing on the page
|
|
12
|
+
// explaining it. That reads as a bug in the gate rather than the gate working.
|
|
13
|
+
//
|
|
14
|
+
// 2. HOW TO RETIRE ONE. R10 made archive refuse and return the work; R14 made it
|
|
15
|
+
// apply a disposition. Both were API-only, so the only way to retire a goal
|
|
16
|
+
// holding work was hand-written JSON — which is why the ADR 0264 goal cut
|
|
17
|
+
// needed a terminal.
|
|
18
|
+
//
|
|
19
|
+
// Static assertions over the shipped browser source, the promote_goal_id.mjs
|
|
20
|
+
// precedent: these are DOM strings a unit test cannot invoke, and the properties
|
|
21
|
+
// worth pinning are structural — which read feeds the panel, what the move-target
|
|
22
|
+
// filter excludes, and that the server's refusal is shown rather than paraphrased.
|
|
23
|
+
//
|
|
24
|
+
// Run: node tests/goal_archive_hall.mjs
|
|
25
|
+
|
|
26
|
+
import { strict as assert } from 'node:assert';
|
|
27
|
+
import { readFileSync } from 'node:fs';
|
|
28
|
+
import { makeRunner } from './helpers.mjs';
|
|
29
|
+
|
|
30
|
+
const { test, summary } = makeRunner();
|
|
31
|
+
const src = (rel) => readFileSync(new URL('../' + rel, import.meta.url), 'utf8');
|
|
32
|
+
const RENDER = src('modules/hall-ui/public/goals-render.js');
|
|
33
|
+
const CSS = src('modules/hall-ui/public/goals.css');
|
|
34
|
+
const ROUTES = src('modules/lifecycle/routes/goals.js');
|
|
35
|
+
|
|
36
|
+
// ---- the server hands the page the same facts the refusal uses --------------
|
|
37
|
+
|
|
38
|
+
await test('GET /goals/:id carries the open tasks, from the SAME read the refusal returns', () => {
|
|
39
|
+
const detail = ROUTES.slice(ROUTES.indexOf("router.get('/goals/:id'"), ROUTES.indexOf("router.get('/goals/:id/suggest-criteria'"));
|
|
40
|
+
assert.match(detail, /db\.openTasksInGoal\(id\)/,
|
|
41
|
+
'the page must not compute "what is in the way" a second way');
|
|
42
|
+
assert.match(detail, /open_tasks: openTasks/, 'and it must reach the client');
|
|
43
|
+
// If the page used a different read, it could show a builder a list the archive
|
|
44
|
+
// refusal then disagrees with — the exact confusion the two-step exists to end.
|
|
45
|
+
const archive = ROUTES.slice(ROUTES.indexOf("router.post('/goals/:id/archive'"));
|
|
46
|
+
assert.match(archive, /db\.openTasksInGoal\(id/, 'the refusal reads it too');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// ---- 1. why the goal is open ------------------------------------------------
|
|
50
|
+
|
|
51
|
+
await test('the completion band names the unfinished work, with each task status', () => {
|
|
52
|
+
const fn = RENDER.slice(RENDER.indexOf('function heldOpenHtml'), RENDER.indexOf('function completionSummaryHtml'));
|
|
53
|
+
assert.match(fn, /holding this goal open/, 'it has to say what the tasks are doing, not merely list them');
|
|
54
|
+
assert.match(fn, /held__status/,
|
|
55
|
+
'the status is the surprising part — `completed` and `confirmed` still hold a goal open');
|
|
56
|
+
assert.match(fn, /#\/task\//, 'and each is reachable, so the next move is one click');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
await test('it is SILENT when nothing is held, and on a closed goal', () => {
|
|
60
|
+
const fn = RENDER.slice(RENDER.indexOf('function heldOpenHtml'), RENDER.indexOf('function completionSummaryHtml'));
|
|
61
|
+
assert.match(fn, /if \(!openTasks \|\| !openTasks\.total \|\| g\.status !== 'open'\) return '';/,
|
|
62
|
+
'an empty "0 tasks" row on the common goal is noise, and this band renders on every goal');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
await test('the summary line stops claiming criteria are the only thing left', () => {
|
|
66
|
+
const fn = RENDER.slice(RENDER.indexOf('function completionSummaryHtml'), RENDER.indexOf('function critListHtml'));
|
|
67
|
+
assert.match(fn, /const held = openTasks && openTasks\.total \? openTasks\.total : 0;/);
|
|
68
|
+
assert.match(fn, /still will not close while work is unfinished/,
|
|
69
|
+
'satisfied criteria plus an open goal is exactly the state that reads as a bug unexplained');
|
|
70
|
+
// The old sentence must survive for the case it is still true for.
|
|
71
|
+
assert.match(fn, /a criterion becomes confirmable once every task linked to it has shipped/);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
await test('both the band and the full view are passed the open tasks', () => {
|
|
75
|
+
// A detail view that renders the summary WITHOUT them silently drops the
|
|
76
|
+
// explanation on the surface a builder opens to investigate.
|
|
77
|
+
const calls = RENDER.match(/completionSummaryHtml\(g, crit, sat, \d+, d\.criterionless_task_count, d\.open_tasks\)/g) || [];
|
|
78
|
+
assert.equal(calls.length, 2, 'the compact band and the full view');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// ---- 2. the archive two-step ------------------------------------------------
|
|
82
|
+
|
|
83
|
+
await test('the Archive control appears only for someone who can manage an OPEN goal', () => {
|
|
84
|
+
const fn = RENDER.slice(RENDER.indexOf('function renderActions'), RENDER.indexOf('function archiveRowHtml'));
|
|
85
|
+
assert.match(fn, /if \(canManage && g\.status === 'open'\) \{/,
|
|
86
|
+
'a Xenos sees the state, not the control — the server enforces either way');
|
|
87
|
+
assert.match(fn, /id="goal-archive-btn"/);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
await test('a goal holding NOTHING archives in one step, with an optional reason', () => {
|
|
91
|
+
const fn = RENDER.slice(RENDER.indexOf('async function openArchivePanel'), RENDER.indexOf('async function submitArchive'));
|
|
92
|
+
assert.match(fn, /held\.total === 0/, 'the common case must not become a two-step');
|
|
93
|
+
assert.match(fn, /Why \(optional\)/);
|
|
94
|
+
assert.match(fn, /reversible/, 'and say so — an archive that reads as permanent is one nobody uses');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
await test('move targets exclude closed goals AND the goal being archived', () => {
|
|
98
|
+
const fn = RENDER.slice(RENDER.indexOf('async function openArchivePanel'), RENDER.indexOf('async function submitArchive'));
|
|
99
|
+
assert.match(fn, /o\.status === 'open' && !sameId\(o\.id, g\.id\)/,
|
|
100
|
+
'a closed goal takes no new work, and moving INTO the goal being archived is the silent discard renamed');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
await test('every task needs a decision before the panel will submit', () => {
|
|
104
|
+
const fn = RENDER.slice(RENDER.indexOf('async function submitArchive'));
|
|
105
|
+
assert.match(fn, /undecided\.push\(id\)/);
|
|
106
|
+
assert.match(fn, /a decision: \$\{undecided\.map/, 'and the refusal names WHICH');
|
|
107
|
+
assert.match(fn, /if \(!reason\) return say\(/, 'an abandon without a reason is the silent discard D3 forbids');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
await test('the client check is a courtesy — the server refusal is shown VERBATIM', () => {
|
|
111
|
+
const fn = RENDER.slice(RENDER.indexOf('async function submitArchive'));
|
|
112
|
+
assert.match(fn, /say\(\(e && e\.message\)/,
|
|
113
|
+
'the server knows things this panel does not — a task claimed since load, a target that just closed');
|
|
114
|
+
// A UI that believes it is the enforcement is how a second, drifting copy of a
|
|
115
|
+
// rule gets written — the panel's own header says so, above the flow.
|
|
116
|
+
const flow = RENDER.slice(RENDER.indexOf('// ---- BV1.R24: the archive two-step'));
|
|
117
|
+
assert.match(flow, /COURTESY, never the gate/);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
await test('the disposition payload is exactly the shape the route validates', () => {
|
|
121
|
+
const fn = RENDER.slice(RENDER.indexOf('async function submitArchive'));
|
|
122
|
+
assert.match(fn, /\{ verb: 'abandon' \}/);
|
|
123
|
+
assert.match(fn, /\{ verb: 'move', goal_id: Number\(v\) \}/);
|
|
124
|
+
assert.match(fn, /body\.dispositions = dispositions;/);
|
|
125
|
+
// The route declares `reason` and `dispositions` and rejects anything else
|
|
126
|
+
// (ADR 0118), so an extra field here would 400 the whole call.
|
|
127
|
+
const sent = fn.slice(0, fn.indexOf('postJSON'));
|
|
128
|
+
assert.equal(/body\.(?!dispositions|reason)[a-z_]+ =/.test(sent), false, 'no field the route does not declare');
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
await test('archiving re-reads the goal rather than patching the page from the response', () => {
|
|
132
|
+
const fn = RENDER.slice(RENDER.indexOf('async function submitArchive'));
|
|
133
|
+
assert.match(fn, /loadDetail\(g\.id, \{ force: true \}\)/,
|
|
134
|
+
'the archive moved tasks between goals — a local patch would leave the page lying about both');
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
await test('the panel uses the page\'s own tokens, not colours of its own', () => {
|
|
138
|
+
const block = CSS.slice(CSS.indexOf('.arch-panel {'));
|
|
139
|
+
assert.equal(/#[0-9a-fA-F]{6}/.test(block.slice(0, block.indexOf('.arch-panel__err'))), false,
|
|
140
|
+
'a panel that announces itself is one people click by accident');
|
|
141
|
+
assert.match(CSS, /\.held-open \{/);
|
|
142
|
+
assert.match(CSS, /\.arch-row__verb \{/);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// ---- 3. it actually RENDERS ------------------------------------------------
|
|
146
|
+
//
|
|
147
|
+
// The assertions above prove the source contains the right strings. These run the
|
|
148
|
+
// two pure builders and check the MARKUP — grammar, escaping, the overflow line,
|
|
149
|
+
// and that no verb is preselected. A static regex passes just as happily on a
|
|
150
|
+
// function that throws.
|
|
151
|
+
|
|
152
|
+
const escapeHtmlFn = (x) => String(x).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
153
|
+
const fmtNumFn = (n) => String(n);
|
|
154
|
+
const lift = (name, end, args, vals) => new Function(...args, `${RENDER.slice(RENDER.indexOf(`function ${name}`), RENDER.indexOf(end))}; return ${name};`)(...vals);
|
|
155
|
+
const heldOpenHtml = lift('heldOpenHtml', 'function completionSummaryHtml', ['escapeHtml', 'fmtNum'], [escapeHtmlFn, fmtNumFn]);
|
|
156
|
+
const archiveRowHtml = lift('archiveRowHtml', 'async function openArchivePanel', ['escapeHtml'], [escapeHtmlFn]);
|
|
157
|
+
const OPEN = { status: 'open' };
|
|
158
|
+
|
|
159
|
+
await test('RENDERS: a goal holding nothing produces no band at all', () => {
|
|
160
|
+
assert.equal(heldOpenHtml(OPEN, { total: 0, tasks: [] }), '');
|
|
161
|
+
assert.equal(heldOpenHtml(OPEN, null), '');
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
await test('RENDERS: an archived goal shows no band even while holding work', () => {
|
|
165
|
+
assert.equal(heldOpenHtml({ status: 'archived' }, { total: 3, tasks: [{ id: 1, title: 'x', status: 'ready' }] }), '');
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
await test('RENDERS: held tasks come out linked, status-tagged and ESCAPED', () => {
|
|
169
|
+
const html = heldOpenHtml(OPEN, { total: 2, tasks: [
|
|
170
|
+
{ id: 1001, title: 'The <b>unescaped</b> one', status: 'confirmed' },
|
|
171
|
+
{ id: 1002, title: 'second', status: 'ready' },
|
|
172
|
+
] });
|
|
173
|
+
assert.match(html, /2 unfinished tasks are holding this goal open/);
|
|
174
|
+
assert.match(html, /#\/task\/1001/);
|
|
175
|
+
assert.match(html, /confirmed/, 'the mid-ship status is the surprising part and must show');
|
|
176
|
+
assert.doesNotMatch(html, /<b>unescaped<\/b>/, 'a task title is user input on a page everyone reads');
|
|
177
|
+
assert.match(html, /<b>/);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
await test('RENDERS: the grammar is singular for one', () => {
|
|
181
|
+
const one = heldOpenHtml(OPEN, { total: 1, tasks: [{ id: 5, title: 'solo', status: 'active' }] });
|
|
182
|
+
assert.match(one, /1 unfinished task is holding this goal open/);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
await test('RENDERS: a long list is capped and says how many are hidden', () => {
|
|
186
|
+
const many = heldOpenHtml(OPEN, { total: 30, tasks: Array.from({ length: 8 }, (_, i) => ({ id: i, title: 't', status: 'ready' })) });
|
|
187
|
+
assert.match(many, /and 22 more/, 'a truncated list that does not say it is truncated is a wrong answer');
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
await test('RENDERS: an archive row preselects NOTHING and offers each open goal', () => {
|
|
191
|
+
const row = archiveRowHtml({ id: 77, title: 'row', status: 'blocked' }, [{ id: 42, title: 'survivor' }]);
|
|
192
|
+
assert.match(row, /value="" selected/, 'a preselected verb is a decision nobody made');
|
|
193
|
+
assert.match(row, /value="abandon"/);
|
|
194
|
+
assert.match(row, /move → #42 survivor/);
|
|
195
|
+
assert.match(row, /data-task="77"/, 'the row carries its id, which is how the map is collected');
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
summary();
|
|
@@ -231,6 +231,13 @@ test('over the four seeded ranks, requirePermission admits exactly who requireRa
|
|
|
231
231
|
const REPLACED_ARCHON = [
|
|
232
232
|
'dependency.cross_tier.manage', 'gate_approval.decide',
|
|
233
233
|
'task.confirm.grade_bypass', 'access_request.review',
|
|
234
|
+
// BV1.R21 (task 1003608): `version.create` RETURNED here. ADR 0157 had lowered it
|
|
235
|
+
// to metic on "authoring delegates, disposition does not"; ADR 0250 §6 amends that
|
|
236
|
+
// for this one key, because D1 makes the version boundary the scope gate and
|
|
237
|
+
// cutting a version is then the way AROUND the gate rather than authoring. Moving
|
|
238
|
+
// it back is an owner decision, which is exactly what this hand-written oracle
|
|
239
|
+
// exists to require — a floor that moved without one still reds the build.
|
|
240
|
+
'version.create',
|
|
234
241
|
];
|
|
235
242
|
// ADR 0157 (owner decision, 2026-08-05) — "the Archon rank is responsible for rank and
|
|
236
243
|
// identity only". Each of these replaced a requireRank('archon') gate in R104/R105 and
|
|
@@ -249,9 +256,6 @@ test('over the four seeded ranks, requirePermission admits exactly who requireRa
|
|
|
249
256
|
'task.peer_votes.tally', 'override_request.decide', 'memory.read.any',
|
|
250
257
|
'project.curate', 'provisioning.fleet.manage', 'security.report.adjudicate',
|
|
251
258
|
'session.search',
|
|
252
|
-
// Minted by R105 (POST /versions had no atom); ADR 0157 then lowered it to metic.
|
|
253
|
-
// Its sibling `version.close` stayed archon — authoring delegates, disposition does not.
|
|
254
|
-
'version.create',
|
|
255
259
|
// Minted BY ADR 0157, splitting the Gate page's READ off `gate_approval.decide` so a
|
|
256
260
|
// Metic can open /gate while approving a held PR stays Archon.
|
|
257
261
|
'gate_approval.read',
|