@bongos/core 1.19.375 → 1.19.377
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 +94 -39
- 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 +5 -1
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/api/openapi.json +76 -5
- package/docs/api-reference.md +4 -3
- package/docs/copy-inventory.md +128 -127
- package/docs/copy-registry.json +175 -156
- package/migrations/core_227_task_created_by.sql +83 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +2 -0
- package/modules/hall-ui/public/goals-lib.js +5 -5
- package/modules/hall-ui/public/goals-page.js +54 -23
- package/modules/hall-ui/public/goals-render.js +15 -0
- package/modules/hall-ui/public/task-lib.js +29 -0
- package/modules/hall-ui/public/work.js +117 -5
- package/modules/ideas/inbox.js +4 -0
- package/modules/lifecycle/db-tasks.js +61 -5
- package/modules/lifecycle/goal-rollup.js +118 -0
- package/modules/lifecycle/kickoff-checklist.js +138 -0
- package/modules/lifecycle/kickoff-seed.js +69 -0
- package/modules/lifecycle/module.json +4 -2
- package/modules/lifecycle/routes/goals.js +26 -0
- package/modules/lifecycle/routes/kickoff-seed.js +29 -0
- package/modules/lifecycle/routes/task-write-routes.js +3 -0
- package/modules/lifecycle/routes/tasks.js +47 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/fitness.js +1 -0
- package/scripts/gds/init.js +9 -79
- package/scripts/gds/test-path-guard.js +99 -0
- package/src/bongos/auth-admission.js +23 -0
- package/src/module-api.js +5 -1
- package/tests/first_admin_kickoff_seed.mjs +56 -0
- package/tests/goal_sort_staleness.mjs +286 -0
- package/tests/goal_subtitle.mjs +6 -1
- package/tests/goals_lib.mjs +16 -4
- package/tests/kickoff_checklist.mjs +107 -0
- package/tests/kickoff_seed.mjs +93 -0
- package/tests/provisioning_onboard_mode.mjs +3 -4
- package/tests/search_ingest.mjs +3 -2
- package/tests/setup_smoke.mjs +4 -2
- package/tests/test_path_guard.mjs +126 -0
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// modules/lifecycle/goal-rollup.js — per-goal task rollups, computed in Postgres
|
|
4
|
+
// instead of in the browser (task 1003278, goal 1000072, criterion
|
|
5
|
+
// legibility-sort-and-staleness).
|
|
6
|
+
//
|
|
7
|
+
// THE MEASUREMENT THAT FORCED THIS. The Goals page derived each goal's readiness
|
|
8
|
+
// counters, its last-activity timestamp and (task 1003278) its craft set by
|
|
9
|
+
// FETCHING EVERY TASK IN THE VERSION — `GET /tasks?version=BONGOS-V1&include=deps`
|
|
10
|
+
// — and reducing them client-side. On the real corpus that request is
|
|
11
|
+
// **5,229,455 bytes for ~1,400 tasks**, measured 2026-08-25, to produce roughly
|
|
12
|
+
// forty small counter objects. The criterion's own words: "~1,357 tasks on
|
|
13
|
+
// BONGOS-V1 alone. A view that does not survive that number is not a solution."
|
|
14
|
+
// It did not survive it; it merely did not fall over.
|
|
15
|
+
//
|
|
16
|
+
// So the reduction moves to where the rows already are. One query per version
|
|
17
|
+
// returns one row per goal, and the payload stops scaling with the task count.
|
|
18
|
+
//
|
|
19
|
+
// THE BUCKETS ARE THE CLIENT'S, VERBATIM. goals-lib.js `readinessOf` is the
|
|
20
|
+
// definition of record and this SQL mirrors it exactly — done (shipped |
|
|
21
|
+
// confirmed | completed) · cut (abandoned | dropped, excluded from the total the
|
|
22
|
+
// way migration 017 keeps them out of version_progress) · active · ready ·
|
|
23
|
+
// blocked (backlog with >=1 unshipped dependency) · backlog. A divergence here
|
|
24
|
+
// would show a person different numbers depending on which surface they opened,
|
|
25
|
+
// so tests/goal_sort_staleness.mjs pins the two against each other.
|
|
26
|
+
//
|
|
27
|
+
// LAST ACTIVITY is `max(updated_at, shipped_at)` — the same "any task activity
|
|
28
|
+
// counts" rule the client uses (task 1002965, the owner's call), now expressed
|
|
29
|
+
// once in SQL and once in task-lib.js because the browser still needs it for
|
|
30
|
+
// rows it holds itself.
|
|
31
|
+
|
|
32
|
+
const api = require('../../src/module-api');
|
|
33
|
+
const { pool } = api;
|
|
34
|
+
|
|
35
|
+
// The status vocabularies, kept beside the SQL that uses them so a reader can
|
|
36
|
+
// check them against goals-lib.js in one glance.
|
|
37
|
+
const DONE_STATUSES = ['shipped', 'confirmed', 'completed'];
|
|
38
|
+
const CUT_STATUSES = ['abandoned', 'dropped'];
|
|
39
|
+
|
|
40
|
+
// goalRollups — one row per goal that has at least one task in `versionId`.
|
|
41
|
+
// Returns [{ goal_id, done, active, ready, blocked, backlog, cut, total,
|
|
42
|
+
// last_activity_at, disciplines[], next_ready_id, next_ready_title }].
|
|
43
|
+
//
|
|
44
|
+
// `blocked` needs the dependency edges, so the CTE resolves each backlog task's
|
|
45
|
+
// unshipped-dependency count once rather than per row. deps.pool honors the #538
|
|
46
|
+
// test seam (NODE_ENV==='test' only), like every other helper in this module.
|
|
47
|
+
// VISIBILITY. A rollup is a COUNT, and counts on a private goal are not the
|
|
48
|
+
// secret — its prose is (ADR 0112's leak rule: a non-member must not learn a
|
|
49
|
+
// private goal's title or scope). But `next_ready_title` is a real task title
|
|
50
|
+
// off a real goal, so this returns it ONLY to a member or an Archon. Everyone
|
|
51
|
+
// else gets the counters and a null title: the board still renders the ring and
|
|
52
|
+
// the readiness line, and the "Claim next" affordance simply has no name to show
|
|
53
|
+
// — which is correct, because they cannot claim it either (task 1003137's
|
|
54
|
+
// membership gate). Raised in review; the pre-existing GET /goals listing has
|
|
55
|
+
// the same unfiltered shape, and tightening THAT is its own task, not something
|
|
56
|
+
// to smuggle in here.
|
|
57
|
+
async function goalRollups({ versionId = null, viewerId = null, viewerIsArchon = false } = {}, deps = {}) {
|
|
58
|
+
const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
|
|
59
|
+
const { rows } = await activePool.query(
|
|
60
|
+
`WITH scoped AS (
|
|
61
|
+
SELECT t.id, t.goal_id, t.status, t.title, t.priority, t.discipline,
|
|
62
|
+
GREATEST(COALESCE(t.updated_at, 'epoch'::timestamptz),
|
|
63
|
+
COALESCE(t.shipped_at, 'epoch'::timestamptz)) AS activity_at,
|
|
64
|
+
EXISTS (
|
|
65
|
+
SELECT 1 FROM dependencies d
|
|
66
|
+
JOIN tasks dep ON dep.id = d.to_id
|
|
67
|
+
WHERE d.from_kind = 'task' AND d.from_id = t.id
|
|
68
|
+
AND d.to_kind = 'task' AND dep.status <> 'shipped'
|
|
69
|
+
) AS has_unshipped_dep
|
|
70
|
+
FROM tasks t
|
|
71
|
+
WHERE t.goal_id IS NOT NULL
|
|
72
|
+
AND ($1::text IS NULL OR t.version_id = $1)
|
|
73
|
+
), bucketed AS (
|
|
74
|
+
SELECT s.*,
|
|
75
|
+
CASE
|
|
76
|
+
WHEN s.status = ANY($2::text[]) THEN 'done'
|
|
77
|
+
WHEN s.status = ANY($3::text[]) THEN 'cut'
|
|
78
|
+
WHEN s.status = 'active' THEN 'active'
|
|
79
|
+
WHEN s.status = 'ready' THEN 'ready'
|
|
80
|
+
WHEN s.status = 'backlog' AND s.has_unshipped_dep THEN 'blocked'
|
|
81
|
+
ELSE 'backlog'
|
|
82
|
+
END AS bucket
|
|
83
|
+
FROM scoped s
|
|
84
|
+
), next_ready AS (
|
|
85
|
+
-- The goal's next claimable task, picked the way the board orders work:
|
|
86
|
+
-- priority first (NULLS LAST), then oldest id. DISTINCT ON gives one row
|
|
87
|
+
-- per goal without a second round trip.
|
|
88
|
+
SELECT DISTINCT ON (goal_id) goal_id, id, title
|
|
89
|
+
FROM bucketed
|
|
90
|
+
WHERE bucket = 'ready'
|
|
91
|
+
ORDER BY goal_id, priority NULLS LAST, id
|
|
92
|
+
)
|
|
93
|
+
SELECT b.goal_id::text AS goal_id,
|
|
94
|
+
COUNT(*) FILTER (WHERE b.bucket = 'done')::int AS done,
|
|
95
|
+
COUNT(*) FILTER (WHERE b.bucket = 'active')::int AS active,
|
|
96
|
+
COUNT(*) FILTER (WHERE b.bucket = 'ready')::int AS ready,
|
|
97
|
+
COUNT(*) FILTER (WHERE b.bucket = 'blocked')::int AS blocked,
|
|
98
|
+
COUNT(*) FILTER (WHERE b.bucket = 'backlog')::int AS backlog,
|
|
99
|
+
COUNT(*) FILTER (WHERE b.bucket = 'cut')::int AS cut,
|
|
100
|
+
COUNT(*) FILTER (WHERE b.bucket <> 'cut')::int AS total,
|
|
101
|
+
MAX(b.activity_at) AS last_activity_at,
|
|
102
|
+
ARRAY_AGG(DISTINCT COALESCE(b.discipline, 'unclassified')) AS disciplines,
|
|
103
|
+
MAX(nr.id)::text AS next_ready_id,
|
|
104
|
+
CASE WHEN bool_or(g.visibility IS DISTINCT FROM 'private')
|
|
105
|
+
OR $4::boolean
|
|
106
|
+
OR bool_or(gm.builder_id IS NOT NULL)
|
|
107
|
+
THEN MAX(nr.title) END AS next_ready_title
|
|
108
|
+
FROM bucketed b
|
|
109
|
+
JOIN goals g ON g.id = b.goal_id
|
|
110
|
+
LEFT JOIN next_ready nr ON nr.goal_id = b.goal_id
|
|
111
|
+
LEFT JOIN goal_members gm ON gm.goal_id = b.goal_id AND gm.builder_id = $5::bigint
|
|
112
|
+
GROUP BY b.goal_id`,
|
|
113
|
+
[versionId, DONE_STATUSES, CUT_STATUSES, viewerIsArchon === true, viewerId]
|
|
114
|
+
);
|
|
115
|
+
return rows;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
module.exports = { goalRollups, DONE_STATUSES, CUT_STATUSES };
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// modules/lifecycle/kickoff-checklist.js — the kickoff checklist: the launch steps a fresh
|
|
2
|
+
// instance needs that the platform CANNOT do for its owner, as task specs (task 1003239).
|
|
3
|
+
//
|
|
4
|
+
// ONE list, two filers. `bongos init` files it at scaffold time on the self-host path
|
|
5
|
+
// (scripts/gds/init.js kickoffTasks); the lifecycle module files it at the founder's
|
|
6
|
+
// FIRST SIGN-IN on the control-plane path (modules/lifecycle/kickoff-seed.js), where
|
|
7
|
+
// the scaffold ran `init --no-seed` and no Archon existed to file anything — walk
|
|
8
|
+
// finding W4: the founder reached an empty board. Moved here from init.js so both
|
|
9
|
+
// filers read the same steps and the copy cannot drift. It lives with the lifecycle
|
|
10
|
+
// module because tasks are its domain and the kernel doorway must not grow a domain
|
|
11
|
+
// import (ADR 0091 §1); init is a core CLI and, like abandon.js or audit-deps.js,
|
|
12
|
+
// may require module code directly. Pure — imports nothing.
|
|
13
|
+
//
|
|
14
|
+
// Each step becomes a real, claimable task so it is tracked, never silently skipped
|
|
15
|
+
// (grounded in the onboarding/deploy surfaces; the task 1204 mapping pass). Per-module
|
|
16
|
+
// prerequisites are appended only for the modules the owner enabled.
|
|
17
|
+
|
|
18
|
+
'use strict';
|
|
19
|
+
|
|
20
|
+
// The one kickoff step the provisioning module AUTOMATES (ADR 0111 §3): standing up
|
|
21
|
+
// production hosting/TLS/DNS/managed-DB. Kept as a named builder so init can either
|
|
22
|
+
// FILE it (no provisioning requested, or the request couldn't be enqueued) or OMIT
|
|
23
|
+
// it (a request went in — the control-plane runner covers this leg).
|
|
24
|
+
function productionTopologyKickoff() {
|
|
25
|
+
return {
|
|
26
|
+
title: 'Production topology: TLS, real origins, managed Postgres',
|
|
27
|
+
kind: 'infra',
|
|
28
|
+
description: 'For a real (non-localhost) launch: front the instance with TLS (Caddy/Cloudflare/nginx), point ' +
|
|
29
|
+
'publicOrigin/buildersOrigin/statusOrigin at the real domain (the OAuth callback depends on this), and move off the bundled container DB volume to managed/backed-up Postgres.',
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// The admission step as the SELF-HOST owner meets it: an env flag on their own box.
|
|
34
|
+
const ADMISSION_SELF_HOST = {
|
|
35
|
+
title: 'Set the admission posture and invite the first builders',
|
|
36
|
+
kind: 'infra',
|
|
37
|
+
description: 'Enrollment is invite-only by default. Beyond you, no one can join until you approve an access request ' +
|
|
38
|
+
'or set <ENVPREFIX>_OPEN_XENOS_ENROLLMENT=1. Decide who can join and invite/approve the first builders.',
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
// The same decision as a HUB-PROVISIONED founder meets it: the door is the project's
|
|
42
|
+
// "How people join" setting on the hub (task 1003044), never an env var they cannot
|
|
43
|
+
// reach; invites are the Watch page's "Invite by GitHub username". Names only surfaces
|
|
44
|
+
// that exist — a blank board is better than untrue copy (task 1003239).
|
|
45
|
+
const ADMISSION_AT_FIRST_SIGN_IN = {
|
|
46
|
+
title: 'Decide how people join, and invite the first builders',
|
|
47
|
+
kind: 'infra',
|
|
48
|
+
description: 'Unless you opened the door when you created the project, nobody beyond you can join until you invite them. ' +
|
|
49
|
+
'Set "How people join" (open / apply to join / invite-only) from your project\'s manage page on the hub, ' +
|
|
50
|
+
'and invite the first builders by GitHub username from your project\'s Watch page.',
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const MERGE_GATE = {
|
|
54
|
+
title: 'Arm the merge gate before a second builder joins (ci deploy mode)',
|
|
55
|
+
kind: 'infra',
|
|
56
|
+
description: 'For a shared instance on ci deploy mode, enable branch protection on main + required checks ' +
|
|
57
|
+
'(unit / secret-scan / gate-review), so untrusted builders cannot push straight to production. No review requirement — gates are CI checks, not human approvals (ADR 0162). Skippable for a solo/local instance.',
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const INSTANCE_LOOK = {
|
|
61
|
+
title: 'Fill the instance look with your own LLM',
|
|
62
|
+
// 'ideator' is a DISCIPLINE, not a kind — tasks_kind_check rejects it as a
|
|
63
|
+
// kind and the task silently vanished from the checklist (the one non-infra
|
|
64
|
+
// kickoff step). Deciding the instance look is a decision by an ideator.
|
|
65
|
+
kind: 'decision',
|
|
66
|
+
discipline: 'ideator',
|
|
67
|
+
description: 'init wrote a lean config/branding.json and left the LOOK to you (a locked project rule: brand with your ' +
|
|
68
|
+
'own LLM, not a generator). Hand docs/branding-fill-prompt.md to your AI to design a palette/fonts/wordmark, or keep the vanilla look.',
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
// Steps that are already TRUE the moment a hub-provisioned founder signs in: auth
|
|
72
|
+
// works (they just used it), the sign-in is this one, and the scaffold bound the repo
|
|
73
|
+
// and chose the deploy mode. Filed on the self-host path only.
|
|
74
|
+
function selfHostOnlySteps(spec, origin) {
|
|
75
|
+
return [
|
|
76
|
+
{
|
|
77
|
+
title: 'Register a GitHub OAuth app and set its credentials',
|
|
78
|
+
kind: 'infra',
|
|
79
|
+
description: 'Auth is the hard prerequisite for every sign-in — without it the server returns 503 ' +
|
|
80
|
+
'auth_not_configured and nobody (including you) can log in. Create a GitHub OAuth app, set ' +
|
|
81
|
+
`<ENVPREFIX>_GITHUB_CLIENT_ID/SECRET, tick "Enable Device Flow" (a separate save), and set the callback URL to ${origin}/api/gds/auth/web/callback.`,
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
title: `Sign in once as ${spec.firstAdmin} to seat the founding owner`,
|
|
85
|
+
kind: 'infra',
|
|
86
|
+
description: `A fresh database has no owner. Signing in as the configured firstAdmin (${spec.firstAdmin}) ` +
|
|
87
|
+
'the first time auto-seats that account as the founding Archon (owner). Config alone does nothing — you must actually sign in.',
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
title: 'Confirm the GitHub repo binding and deploy mode',
|
|
91
|
+
kind: 'infra',
|
|
92
|
+
description: `This instance ships work to ${spec.repo.owner}/${spec.repo.name}. Confirm the git remote / repo binding ` +
|
|
93
|
+
'and pick deploy mode in config/deploy.json (ci = server-mediated PR auto-merge, or laptop). A wrong/absent repo binding lets a task grade but never merge.',
|
|
94
|
+
},
|
|
95
|
+
];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Per-module prerequisites — a key the module cannot run without.
|
|
99
|
+
function moduleKeySteps(spec) {
|
|
100
|
+
const enabled = new Set(Array.isArray(spec.modules) ? spec.modules : []);
|
|
101
|
+
const tasks = [];
|
|
102
|
+
if (enabled.has('art-pipeline')) {
|
|
103
|
+
tasks.push({ title: 'Provide a Google Gemini API key for the art pipeline', kind: 'infra',
|
|
104
|
+
description: 'The art-pipeline module needs a Google AI Studio (Gemini) key (GOOGLE_AI_STUDIO_KEY, a shared key, or per-builder keys) or it cannot generate art.' });
|
|
105
|
+
}
|
|
106
|
+
if (enabled.has('discord')) {
|
|
107
|
+
tasks.push({ title: 'Provide Discord bot token + webhooks', kind: 'infra',
|
|
108
|
+
description: 'The discord module needs a bot token + webhook URLs + DISCORD_BOT_ENABLED=true. Note: outbound/link surfaces fire on credentials even if the module flag is later turned off.' });
|
|
109
|
+
}
|
|
110
|
+
if (enabled.has('dev-box')) {
|
|
111
|
+
tasks.push({ title: 'Provide a DigitalOcean API token for dev boxes', kind: 'infra',
|
|
112
|
+
description: 'The dev-box module needs a DigitalOcean API token (and optionally Cloudflare) on the operator control plane before any builder box can be provisioned.' });
|
|
113
|
+
}
|
|
114
|
+
return tasks;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// kickoffChecklist(spec, opts) → task specs ({ title, kind, description[, discipline] }).
|
|
118
|
+
// provisioned — the owner opted into provisioning at init: the control-plane runner
|
|
119
|
+
// covers production topology, so that step is omitted (ADR 0111 §3).
|
|
120
|
+
// firstSignIn — filed the moment the founder of a hub-provisioned project is seated:
|
|
121
|
+
// only the steps still true after the standup, with the admission door
|
|
122
|
+
// described as the hub setting it now is.
|
|
123
|
+
// Pure — no I/O; the caller files them.
|
|
124
|
+
function kickoffChecklist(spec = {}, { provisioned = false, firstSignIn = false } = {}) {
|
|
125
|
+
const origin = (spec.domains && spec.domains.publicOrigin) || 'http://localhost:3000';
|
|
126
|
+
const tasks = firstSignIn ? [] : selfHostOnlySteps(spec, origin);
|
|
127
|
+
tasks.push(firstSignIn ? ADMISSION_AT_FIRST_SIGN_IN : ADMISSION_SELF_HOST);
|
|
128
|
+
tasks.push(MERGE_GATE);
|
|
129
|
+
// Production topology is either AUTOMATED (the owner opted into provisioning — the
|
|
130
|
+
// control-plane runner stands up TLS/DNS/managed-DB) or a tracked manual step.
|
|
131
|
+
// Keep it in its infra slot, before the look step, when it's still manual.
|
|
132
|
+
if (!provisioned && !firstSignIn) tasks.push(productionTopologyKickoff());
|
|
133
|
+
tasks.push(INSTANCE_LOOK);
|
|
134
|
+
tasks.push(...moduleKeySteps(spec));
|
|
135
|
+
return tasks.map((t) => ({ ...t }));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
module.exports = { kickoffChecklist, productionTopologyKickoff };
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// modules/lifecycle/kickoff-seed.js — file the kickoff checklist at the founder's first
|
|
2
|
+
// sign-in (task 1003239; behind the `lifecycle.kickoffSeed` port).
|
|
3
|
+
//
|
|
4
|
+
// WHY HERE, WHY THEN. `bongos init` files the kickoff checklist as tasks — but the
|
|
5
|
+
// control-plane scaffold runs `init --no-seed` (scripts/gds/provision-repo.js), and a
|
|
6
|
+
// hub-provisioned project has no Archon until its founder signs in, so nothing could
|
|
7
|
+
// be filed earlier: the founder reached an EMPTY board (walk finding W4). The kernel's
|
|
8
|
+
// first-admin seating (src/bongos/auth-admission.js) resolves this port the moment the
|
|
9
|
+
// owner is seated — the first moment the checklist CAN be filed.
|
|
10
|
+
//
|
|
11
|
+
// IDEMPOTENT BY EMPTINESS, not by path. It seeds only when the tasks table is empty:
|
|
12
|
+
// the self-host path already filed its checklist before anyone signed in, so it is
|
|
13
|
+
// skipped there with no "was I provisioned" marker to keep true. The caller treats a
|
|
14
|
+
// failure as best-effort — a seed hiccup must never fail the sign-in that just seated
|
|
15
|
+
// the owner.
|
|
16
|
+
//
|
|
17
|
+
// THE BOUNDARY (ADR 0093 §1): core is reached only through ../../src/module-api.js.
|
|
18
|
+
|
|
19
|
+
'use strict';
|
|
20
|
+
|
|
21
|
+
const api = require('../../src/module-api');
|
|
22
|
+
const db = require('./db');
|
|
23
|
+
const { kickoffChecklist } = require('./kickoff-checklist');
|
|
24
|
+
|
|
25
|
+
// The spec the checklist reads, from the instance's own branding pack. `modules` is
|
|
26
|
+
// empty on purpose: a hub-provisioned project is scaffolded with every optional module
|
|
27
|
+
// off, and its founder cannot have enabled one before their first sign-in.
|
|
28
|
+
function specFromBranding(b = api.branding()) {
|
|
29
|
+
return {
|
|
30
|
+
firstAdmin: b.firstAdmin,
|
|
31
|
+
repo: b.repo || {},
|
|
32
|
+
domains: { publicOrigin: b.domains && b.domains.publicOrigin },
|
|
33
|
+
modules: [],
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// The version the checklist lands under: the building one, else the first by id. The
|
|
38
|
+
// scaffold seeds exactly one (provision-repo.js seedFirstVersionCmd).
|
|
39
|
+
const FIRST_VERSION_SQL = `SELECT id FROM versions ORDER BY (status = 'building') DESC, id ASC LIMIT 1`;
|
|
40
|
+
|
|
41
|
+
// seedKickoffIfEmpty({ builderId }, deps) → { seeded, versionId } | { seeded: 0, skipped }.
|
|
42
|
+
// deps.pool / deps.createTask / deps.spec are test seams; production reads the doorway.
|
|
43
|
+
async function seedKickoffIfEmpty({ builderId = null } = {}, deps = {}) {
|
|
44
|
+
const pool = deps.pool || api.pool;
|
|
45
|
+
const insert = deps.createTask || ((row) => db.createTask(row));
|
|
46
|
+
const { rows: [{ n }] } = await pool.query('SELECT count(*)::int AS n FROM tasks');
|
|
47
|
+
if (n > 0) return { seeded: 0, skipped: 'tasks_exist' };
|
|
48
|
+
const { rows: versions } = await pool.query(FIRST_VERSION_SQL);
|
|
49
|
+
if (!versions.length) return { seeded: 0, skipped: 'no_version' };
|
|
50
|
+
const versionId = versions[0].id;
|
|
51
|
+
const steps = kickoffChecklist(deps.spec || specFromBranding(), { firstSignIn: true });
|
|
52
|
+
let seeded = 0;
|
|
53
|
+
for (const t of steps) {
|
|
54
|
+
await insert({
|
|
55
|
+
versionId,
|
|
56
|
+
title: t.title,
|
|
57
|
+
description: t.description || '',
|
|
58
|
+
status: 'ready',
|
|
59
|
+
kind: t.kind || 'infra',
|
|
60
|
+
discipline: t.discipline || 'engineer',
|
|
61
|
+
source: 'first-sign-in',
|
|
62
|
+
createdBy: builderId,
|
|
63
|
+
});
|
|
64
|
+
seeded += 1;
|
|
65
|
+
}
|
|
66
|
+
return { seeded, versionId };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
module.exports = { seedKickoffIfEmpty, specFromBranding, FIRST_VERSION_SQL };
|
|
@@ -21,14 +21,16 @@
|
|
|
21
21
|
"help-requests",
|
|
22
22
|
"recommendations",
|
|
23
23
|
"closeness",
|
|
24
|
-
"visuals"
|
|
24
|
+
"visuals",
|
|
25
|
+
"kickoff-seed"
|
|
25
26
|
],
|
|
26
27
|
"pollers": [
|
|
27
28
|
"publish-reconciler"
|
|
28
29
|
]
|
|
29
30
|
},
|
|
30
31
|
"provides": [
|
|
31
|
-
"lifecycle"
|
|
32
|
+
"lifecycle",
|
|
33
|
+
"lifecycle.kickoffSeed"
|
|
32
34
|
],
|
|
33
35
|
"consumes": []
|
|
34
36
|
}
|
|
@@ -29,6 +29,7 @@ const hierarchy = require('../hierarchy');
|
|
|
29
29
|
const goalSuggest = require('../goal-suggest.js'); // task 1003062 — pre-creation goal suggester
|
|
30
30
|
const goalEdits = require('../goal-edits.js'); // task 1003102 — goal self-edit handlers
|
|
31
31
|
const goalCategory = require('../goal-category.js'); // task 1003275 — the ADVISORY human category (gates nothing)
|
|
32
|
+
const goalRollup = require('../goal-rollup.js'); // task 1003278 — per-goal rollups computed in SQL, not in the browser
|
|
32
33
|
const { suggestCriteria } = require('../criterion-suggest');
|
|
33
34
|
const { classifyTaskKind, VALID_KINDS, VALID_DISCIPLINES, TASK_FIELD_BOUNDS } = require('../task-classifier');
|
|
34
35
|
const { validateOrRespond, parseId, LIMITS } = api;
|
|
@@ -145,6 +146,29 @@ module.exports = function buildGoalsRouter() {
|
|
|
145
146
|
}
|
|
146
147
|
});
|
|
147
148
|
|
|
149
|
+
// GET /goals/rollups?version= — one small row per goal: readiness counters, the
|
|
150
|
+
// last-activity timestamp, the craft set, and the next claimable task
|
|
151
|
+
// (task 1003278). It replaces the Goals page's whole-version task fetch, which
|
|
152
|
+
// measured 5,229,455 bytes for ~1,400 tasks on 2026-08-25 to produce ~40 counter
|
|
153
|
+
// objects. Registered BEFORE /goals/:id or Express routes 'rollups' to the param.
|
|
154
|
+
// rank: any-builder — the same read as the goal board it feeds.
|
|
155
|
+
router.get('/goals/rollups', auth.requireBuilder, async (req, res) => {
|
|
156
|
+
try {
|
|
157
|
+
// The viewer decides whether a PRIVATE goal's next-ready task TITLE comes
|
|
158
|
+
// back (counters always do — a count is not the secret, the prose is;
|
|
159
|
+
// ADR 0112). Identity from the authenticated request, never the query.
|
|
160
|
+
const rollups = await goalRollup.goalRollups({
|
|
161
|
+
versionId: req.query.version || null,
|
|
162
|
+
viewerId: req.builder.id,
|
|
163
|
+
viewerIsArchon: req.builder.rank === 'archon',
|
|
164
|
+
});
|
|
165
|
+
return res.json({ rollups });
|
|
166
|
+
} catch (err) {
|
|
167
|
+
log.error('[gds] GET /goals/rollups', err);
|
|
168
|
+
return res.fail('rollups_failed', { status: 500, message: 'internal error' });
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
|
|
148
172
|
// GET /goals?version=&status= — list goals (read).
|
|
149
173
|
// rank: any-builder — viewing the goal board.
|
|
150
174
|
router.get('/goals', auth.requireBuilder, async (req, res) => {
|
|
@@ -1150,6 +1174,8 @@ module.exports = function buildGoalsRouter() {
|
|
|
1150
1174
|
discipline,
|
|
1151
1175
|
source: 'goal-authored',
|
|
1152
1176
|
goalId: goal.id,
|
|
1177
|
+
// task 1003278 — attribution from the authenticated request (never the body).
|
|
1178
|
+
createdBy: req.builder.id,
|
|
1153
1179
|
});
|
|
1154
1180
|
|
|
1155
1181
|
// Attach the sub-task parent (validated above; attachParent re-checks the
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// modules/lifecycle/routes/kickoff-seed.js — registers the `lifecycle.kickoffSeed` port
|
|
4
|
+
// (task 1003239): the founder's first board, filed the moment the kernel seats them.
|
|
5
|
+
//
|
|
6
|
+
// Resolved by ONE caller — the first-admin bootstrap in src/bongos/auth-admission.js —
|
|
7
|
+
// through resolveOptional, so an instance without this module signs in exactly as
|
|
8
|
+
// before. A route factory for a routeless capability, for the reason routes/lifecycle.js
|
|
9
|
+
// and routes/rank-sync.js give: mountModuleRoutes is the boot hook that runs in the live
|
|
10
|
+
// server AND whenever a test rebuilds the router. The router stays EMPTY — seeding has
|
|
11
|
+
// no HTTP surface, and an endpoint here would let anyone re-file a founder's checklist.
|
|
12
|
+
|
|
13
|
+
const express = require('express');
|
|
14
|
+
const api = require('../../../src/module-api');
|
|
15
|
+
const seed = require('../kickoff-seed');
|
|
16
|
+
|
|
17
|
+
// Delegates at call time (never spreads) so a stubbed seed still bites.
|
|
18
|
+
const kickoffSeedPort = {
|
|
19
|
+
seedIfEmpty: (...a) => seed.seedKickoffIfEmpty(...a),
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
module.exports = function buildLifecycleKickoffSeedRouter() {
|
|
23
|
+
if (!api.hasProvider('lifecycle.kickoffSeed')) {
|
|
24
|
+
api.registerProvider('lifecycle.kickoffSeed', kickoffSeedPort);
|
|
25
|
+
}
|
|
26
|
+
return express.Router();
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
module.exports.kickoffSeedPort = kickoffSeedPort;
|
|
@@ -195,6 +195,9 @@ module.exports = function registerTaskWriteRoutes(router) {
|
|
|
195
195
|
requiresRank: body.requires_rank ?? null,
|
|
196
196
|
moduleKey: body.module_key ?? null,
|
|
197
197
|
goalId: body.goal_id ?? null,
|
|
198
|
+
// task 1003278 — attribution, from the AUTHENTICATED request, never the
|
|
199
|
+
// body: who created a task is a fact about the caller, not an input.
|
|
200
|
+
createdBy: req.builder.id,
|
|
198
201
|
});
|
|
199
202
|
// task 438: link resolved criteria (source='planning' — task-create is the
|
|
200
203
|
// planning vector). The ids were version-checked above, so this is a single
|
|
@@ -71,6 +71,25 @@ module.exports = function buildTasksRouter() {
|
|
|
71
71
|
const router = express.Router();
|
|
72
72
|
|
|
73
73
|
// rank: any-builder — reads (filtered task list); preserved per CRITICAL §4.
|
|
74
|
+
// task 1003278 — shared by GET /tasks and GET /tasks/claimable, so the two
|
|
75
|
+
// surfaces cannot drift on what a valid creator id or age window is. Returns
|
|
76
|
+
// undefined (absent), a number, or { bad } for the caller to turn into a 400.
|
|
77
|
+
const posInt = (raw, code, max) => {
|
|
78
|
+
if (raw === undefined || raw === '') return undefined;
|
|
79
|
+
const n = Number(raw);
|
|
80
|
+
if (!Number.isInteger(n) || n <= 0 || (max && n > max)) return { bad: code };
|
|
81
|
+
return n;
|
|
82
|
+
};
|
|
83
|
+
// 'none' is a REAL value here — the unattributed set ("what has no owner"), not
|
|
84
|
+
// a missing filter. Anything else must be a positive builder id.
|
|
85
|
+
function parseCreatedBy(raw) {
|
|
86
|
+
if (raw === undefined || raw === '') return undefined;
|
|
87
|
+
if (raw === 'none') return 'none';
|
|
88
|
+
const n = Number(raw);
|
|
89
|
+
return (Number.isInteger(n) && n > 0) ? n : { bad: 'bad_created_by' };
|
|
90
|
+
}
|
|
91
|
+
const CREATED_BY_MSG = "created_by must be a positive builder id, or 'none' for the unattributed.";
|
|
92
|
+
|
|
74
93
|
router.get('/tasks', auth.requireBuilder, async (req, res) => {
|
|
75
94
|
const { version, status, kind, discipline, goal_id } = req.query;
|
|
76
95
|
if (kind && !VALID_KINDS.has(kind)) {
|
|
@@ -88,7 +107,25 @@ module.exports = function buildTasksRouter() {
|
|
|
88
107
|
return res.fail('bad_goal_id', { status: 400, message: 'goal_id must be a positive integer.' });
|
|
89
108
|
}
|
|
90
109
|
}
|
|
91
|
-
|
|
110
|
+
// task 1003278 — created_by / stale_days / limit / offset, so a board can ask
|
|
111
|
+
// the server a narrow question instead of reducing the whole table in the
|
|
112
|
+
// browser. All four are optional; omitting them is exactly today's behaviour.
|
|
113
|
+
const { created_by: createdByRaw, stale_days: staleRaw, limit: limitRaw, offset: offsetRaw } = req.query;
|
|
114
|
+
const createdBy = parseCreatedBy(createdByRaw);
|
|
115
|
+
if (createdBy && createdBy.bad) return res.fail('bad_created_by', { status: 400, message: CREATED_BY_MSG });
|
|
116
|
+
const staleDays = posInt(staleRaw, 'bad_stale_days', 3650);
|
|
117
|
+
if (staleDays && staleDays.bad) return res.fail('bad_stale_days', { status: 400, message: 'stale_days must be a positive integer number of days.' });
|
|
118
|
+
// The cap is the point of the parameter: an unbounded limit would be the
|
|
119
|
+
// whole-table read this axis exists to stop.
|
|
120
|
+
const limit = posInt(limitRaw, 'bad_limit', 1000);
|
|
121
|
+
if (limit && limit.bad) return res.fail('bad_limit', { status: 400, message: 'limit must be a positive integer up to 1000.' });
|
|
122
|
+
const offset = posInt(offsetRaw, 'bad_offset');
|
|
123
|
+
if (offset && offset.bad) return res.fail('bad_offset', { status: 400, message: 'offset must be a positive integer.' });
|
|
124
|
+
|
|
125
|
+
const tasks = await db.listTasks({
|
|
126
|
+
versionId: version, status, kind, discipline, goalId,
|
|
127
|
+
createdBy, staleDays, limit, offset,
|
|
128
|
+
});
|
|
92
129
|
// ?include=deps (hall redesign task 1002439) — enrich each task with its
|
|
93
130
|
// dependency summary (`blocked_by[]` with per-target satisfied state, and a
|
|
94
131
|
// `blocks` count) so the Goals rollup + Tasks dependency view can paint
|
|
@@ -117,12 +154,21 @@ module.exports = function buildTasksRouter() {
|
|
|
117
154
|
if (fit && !VALID_FITS.has(fit)) {
|
|
118
155
|
return res.fail('bad_fit', 400, { valid: Array.from(VALID_FITS) });
|
|
119
156
|
}
|
|
157
|
+
// task 1003278 — the creator + staleness axes, so the Work Board narrows the
|
|
158
|
+
// feed at the SOURCE instead of downloading it and reducing it in the browser.
|
|
159
|
+
// Same validators as GET /tasks: one vocabulary, two surfaces.
|
|
160
|
+
const claimCreatedBy = parseCreatedBy(req.query.created_by);
|
|
161
|
+
if (claimCreatedBy && claimCreatedBy.bad) return res.fail('bad_created_by', { status: 400, message: CREATED_BY_MSG });
|
|
162
|
+
const claimStaleDays = posInt(req.query.stale_days, 'bad_stale_days', 3650);
|
|
163
|
+
if (claimStaleDays && claimStaleDays.bad) return res.fail('bad_stale_days', { status: 400, message: 'stale_days must be a positive integer number of days.' });
|
|
120
164
|
const [claimable, active] = await Promise.all([
|
|
121
165
|
db.listClaimableTasks({
|
|
122
166
|
versionId: version || null,
|
|
123
167
|
builderId: req.builder.id,
|
|
124
168
|
discipline: discipline || null,
|
|
125
169
|
fit: fit || null,
|
|
170
|
+
createdBy: claimCreatedBy ?? null,
|
|
171
|
+
staleDays: claimStaleDays ?? null,
|
|
126
172
|
}),
|
|
127
173
|
db.listActiveClaims(),
|
|
128
174
|
]);
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.377",
|
|
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.377",
|
|
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.377",
|
|
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/fitness.js
CHANGED
|
@@ -1308,6 +1308,7 @@ const CHECKS = [
|
|
|
1308
1308
|
require('./routine-schedule.js').checkRoutineCadencesScheduled, // Check 25 — task 1003209: a routine that declares a cadence must have a scheduler firing it AT that period (ADR 0188)
|
|
1309
1309
|
require('./baseline-staleness.js').checkBaselineFloors, // Check 26 — task 1003210: a shrink-only baseline that never shrinks is unmanaged debt; a floor exempting one must say why
|
|
1310
1310
|
require('./client-baseurl-guard.js').checkClientBaseUrl,
|
|
1311
|
+
require('./test-path-guard.js').checkTestsUseFileUrlToPath, // Check 30 — task 1002889: a test builds its repo root with fileURLToPath, never URL.pathname (a %20 or a Windows drive slash makes require() throw at load; CI's spaceless checkout can't see it)
|
|
1311
1312
|
// Check 29 — task 1003275 / ADR 0189: a work category ORIENTS a human and steers
|
|
1312
1313
|
// Claude; it authorises nothing. The owner said so twice, and the goal's own prose
|
|
1313
1314
|
// predicts the failure: a second thing that looks like a hierarchy gets mistaken
|