@bongos/core 1.20.36 → 1.20.38

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.
Files changed (53) hide show
  1. package/.bongos-core.json +104 -49
  2. package/.claude/skills/backlog-review/SKILL.md +1 -1
  3. package/.claude/skills/bongos-feedback/SKILL.md +66 -0
  4. package/.claude/skills/collab-review/SKILL.md +1 -1
  5. package/.claude/skills/priority-session/SKILL.md +1 -1
  6. package/.claude/skills/session-handoff/SKILL.md +1 -1
  7. package/.claude/skills/status/SKILL.md +1 -1
  8. package/bin/bongos.js +1 -0
  9. package/clients/bongos-client/README.md +1 -1
  10. package/clients/bongos-client/bongos-client.global.js +8 -0
  11. package/clients/bongos-client/index.cjs +8 -0
  12. package/clients/bongos-client/index.d.ts +12 -1
  13. package/clients/bongos-client/index.mjs +8 -0
  14. package/docs/adr/0136-update-channel-subscription-policy.md +1 -1
  15. package/docs/adr/0357-the-update-rule-set-on-deploy-is-the-one-the-sweep-follows.md +41 -0
  16. package/docs/adr/README.md +1 -0
  17. package/docs/api/openapi.json +188 -2
  18. package/docs/api-reference.md +12 -4
  19. package/docs/architecture.md +2 -0
  20. package/docs/copy-inventory.md +9 -9
  21. package/docs/copy-registry.json +9 -9
  22. package/docs/file-map.md +1 -0
  23. package/docs/module-api-changelog.md +4 -0
  24. package/docs/page-readings.json +2 -2
  25. package/docs/recipes/upgrading-the-core.md +2 -0
  26. package/modules/autonomy/db.js +39 -8
  27. package/modules/autonomy/fence.js +26 -4
  28. package/modules/autonomy/migrations/autonomy_003_priority_goal.sql +32 -0
  29. package/modules/autonomy/routes/autonomy.js +28 -1
  30. package/modules/hall-ui/public/gate.js +33 -4
  31. package/modules/ideas/routing.js +8 -1
  32. package/modules/platform-identity/instance-feedback.js +142 -0
  33. package/modules/platform-identity/module.json +1 -1
  34. package/modules/platform-identity/routes/sso.js +62 -0
  35. package/package-lock.json +2 -2
  36. package/package.json +1 -1
  37. package/release-notes.json +30 -0
  38. package/scripts/gds/autobongos-run.js +121 -16
  39. package/scripts/gds/feedback-send.js +131 -0
  40. package/scripts/gds/module-assess-tests.js +248 -0
  41. package/scripts/gds/update-channel.js +82 -0
  42. package/src/bongos/routes/feedback.js +110 -0
  43. package/src/bongos/routes.js +2 -0
  44. package/src/module-api.js +1 -1
  45. package/tests/autobongos_fence.mjs +62 -0
  46. package/tests/autobongos_loop.mjs +281 -0
  47. package/tests/autonomy_fence_priority.mjs +211 -0
  48. package/tests/bongos_cli.mjs +7 -1
  49. package/tests/filing_abuse_matrix.mjs +7 -2
  50. package/tests/idea_routing.mjs +1 -1
  51. package/tests/instance_feedback.mjs +327 -0
  52. package/tests/module_assess_tests.mjs +232 -0
  53. package/tests/update_channel_db.mjs +124 -0
@@ -2976,7 +2976,7 @@
2976
2976
  "surface": "builders-hall",
2977
2977
  "text": "Allow goal",
2978
2978
  "file": "modules/hall-ui/public/gate.js",
2979
- "line": 288,
2979
+ "line": 303,
2980
2980
  "origin": "js-markup",
2981
2981
  "confidence": "certain"
2982
2982
  },
@@ -3003,7 +3003,7 @@
3003
3003
  "surface": "builders-hall",
3004
3004
  "text": "Allowlisted goals",
3005
3005
  "file": "modules/hall-ui/public/gate.js",
3006
- "line": 286,
3006
+ "line": 300,
3007
3007
  "origin": "js-markup",
3008
3008
  "confidence": "certain"
3009
3009
  },
@@ -3741,7 +3741,7 @@
3741
3741
  "surface": "builders-hall",
3742
3742
  "text": "Changes touching protected build surfaces, held for your approval.",
3743
3743
  "file": "modules/hall-ui/public/gate.js",
3744
- "line": 403,
3744
+ "line": 432,
3745
3745
  "origin": "js-text-assign",
3746
3746
  "confidence": "certain"
3747
3747
  },
@@ -5370,7 +5370,7 @@
5370
5370
  "surface": "builders-hall",
5371
5371
  "text": "Enter a goal number.",
5372
5372
  "file": "modules/hall-ui/public/gate.js",
5373
- "line": 300,
5373
+ "line": 315,
5374
5374
  "origin": "js-notice",
5375
5375
  "confidence": "certain"
5376
5376
  },
@@ -6054,7 +6054,7 @@
6054
6054
  "surface": "builders-hall",
6055
6055
  "text": "Goal {…}",
6056
6056
  "file": "modules/hall-ui/public/gate.js",
6057
- "line": 280,
6057
+ "line": 288,
6058
6058
  "origin": "js-markup",
6059
6059
  "confidence": "certain"
6060
6060
  },
@@ -6063,7 +6063,7 @@
6063
6063
  "surface": "builders-hall",
6064
6064
  "text": "Goal {…} allowlisted.",
6065
6065
  "file": "modules/hall-ui/public/gate.js",
6066
- "line": 303,
6066
+ "line": 318,
6067
6067
  "origin": "js-notice",
6068
6068
  "confidence": "certain"
6069
6069
  },
@@ -6072,7 +6072,7 @@
6072
6072
  "surface": "builders-hall",
6073
6073
  "text": "Goal {…} removed.",
6074
6074
  "file": "modules/hall-ui/public/gate.js",
6075
- "line": 313,
6075
+ "line": 341,
6076
6076
  "origin": "js-notice",
6077
6077
  "confidence": "certain"
6078
6078
  },
@@ -14442,7 +14442,7 @@
14442
14442
  "surface": "builders-hall",
14443
14443
  "text": "last check-in {…} ago",
14444
14444
  "file": "modules/hall-ui/public/gate.js",
14445
- "line": 360,
14445
+ "line": 391,
14446
14446
  "origin": "js-markup",
14447
14447
  "confidence": "certain"
14448
14448
  },
@@ -15846,7 +15846,7 @@
15846
15846
  "surface": "builders-hall",
15847
15847
  "text": "why (optional)",
15848
15848
  "file": "modules/hall-ui/public/gate.js",
15849
- "line": 288,
15849
+ "line": 303,
15850
15850
  "origin": "js-markup",
15851
15851
  "confidence": "certain"
15852
15852
  },
package/docs/file-map.md CHANGED
@@ -365,6 +365,7 @@ tests/
365
365
  ├── backlog-review/SKILL.md ← /backlog-review — daily walk of status=backlog, the pre-workable state a human must say go on: splits rows waiting on a PERSON (promote / kill / water) from rows waiting on a live dep TRIGGER (counted, never walked, migration 163) and surfaces rows stranded behind an abandoned dep; runs scripts/gds/backlog-review.js (task 1003746); Metic+
366
366
  ├── blocker-review/SKILL.md ← /blocker-review — daily review of open Bongos blockers (resolve / escalate / note); Metic+
367
367
  ├── blocker-solve/SKILL.md ← /blocker-solve N — drive ONE blocker to done: do the doable parts, hand back owner-only steps, verify, auto-resolve (auto-promotes waiters); Metic+
368
+ ├── bongos-feedback/SKILL.md ← /bongos-feedback — send feedback about Bongos itself upstream to the Cloud Bongos maintainers via `bongos feedback` (a bug lands as a backlog task in the maintenance goal, an idea in the hub inbox); backed by scripts/gds/feedback-send.js (task 1004462)
368
369
  ├── bug-triage/SKILL.md ← /bug-triage — daily walk through open kind=bug tasks (batch-cluster near-dups, merge / won't-fix); the idea-triage counterpart built on the new tasks.merge primitive (task 1001478); Metic+
369
370
  ├── builder-backup/SKILL.md ← /builder-backup — check DB backup status or trigger a fresh local dump before risky changes (migrations, destructive SQL); no SSH required
370
371
  ├── builder-claim/SKILL.md ← /builder-claim N — atomic claim; prints the claim-time context pack + discipline playbook routing
@@ -2729,5 +2729,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
2729
2729
  landed since 1.20.34 with no explicit bump. run 36787635391. (task 1002620)
2730
2730
  1.20.36 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2731
2731
  landed since 1.20.35 with no explicit bump. run 36788696388. (task 1002620)
2732
+ 1.20.37 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2733
+ landed since 1.20.36 with no explicit bump. run 36790298715. (task 1002620)
2734
+ 1.20.38 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2735
+ landed since 1.20.37 with no explicit bump. run 36794921587. (task 1002620)
2732
2736
  ---------------------------------------------------------------------------
2733
2737
  ```
@@ -873,7 +873,7 @@
873
873
  "surface": "builders",
874
874
  "title": "Gate",
875
875
  "states_read": ["queue","out","approve"],
876
- "files_hash": "d8acda02b90e0cb5",
876
+ "files_hash": "14ac6944f79fb3cb",
877
877
  "reading_hash": "08d3e5d3657e5afb",
878
878
  "counts": {"lines":41,"placed":17,"shared":5,"unplaced":19},
879
879
  "omitted": {"data":34},
@@ -884,7 +884,7 @@
884
884
  {"key":"L0003","section":"top bar","text":"Jump to…","placement":"shared","file":"modules/hall-ui/public/shell.js","line":487,"string_id":"eba610d25523"},
885
885
  {"key":"L0004","section":"top bar","text":"Ctrl K","placement":"shared"},
886
886
  {"key":"L0005","section":"Gate","text":"Gate","placement":"unplaced"},
887
- {"key":"L0006","section":"Gate","text":"Changes touching protected build surfaces, held for your approval.","placement":"placed","file":"modules/hall-ui/public/gate.js","line":403,"string_id":"4b714191b423"},
887
+ {"key":"L0006","section":"Gate","text":"Changes touching protected build surfaces, held for your approval.","placement":"placed","file":"modules/hall-ui/public/gate.js","line":431,"string_id":"4b714191b423"},
888
888
  {"key":"L0007","section":"Gate","text":"Awaiting your approval","placement":"unplaced"},
889
889
  {"key":"L0008","section":"Gate","text":"held at the gate check","placement":"unplaced"},
890
890
  {"key":"L0009","section":"Gate","text":"Hard-floor holds","placement":"unplaced"},
@@ -90,6 +90,8 @@ The sections above are the *manual* `bongos upgrade`. The **subscription** runs
90
90
 
91
91
  A **major** is never automatic; prereleases are never auto-targeted.
92
92
 
93
+ **Where the channel comes from** ([ADR 0357](../adr/0357-the-update-rule-set-on-deploy-is-the-one-the-sweep-follows.md), task [1004468](https://cloudbongos.com/builders#/task/1004468)): the rule set on **/deploy** (`provisioning_instances.update_channel`) wins for any roster entry whose `slug` matches a project row. The roster's `channel` below is only the fallback, used when no row matches or the sweep can't read the database. The sweep reads the control plane's DB from its own `DATABASE_URL` / `PGDATABASE`, so the unit needs one of them set. Every run prints each entry's channel and its source.
94
+
93
95
  **Enrolling an instance** — add it to `config/update-subscriptions.json` (instance-side) (ships empty). `env` carries per-instance vars merged into the upgrade child — set `PGDATABASE` so `migrate` hits the right DB (see the Gotchas):
94
96
 
95
97
  ```json
@@ -76,7 +76,7 @@ async function readAutonomyRuns({ routine = null, limit = 20 } = {}) {
76
76
  // it unreadable to the caller, which refuses — never a permissive default.
77
77
  async function readFence() {
78
78
  const [fence, goals] = await Promise.all([
79
- pool.query('SELECT enabled, paused_reason, updated_at, updated_by FROM autonomy_fence WHERE id = 1'),
79
+ pool.query('SELECT enabled, paused_reason, priority_goal_id, updated_at, updated_by FROM autonomy_fence WHERE id = 1'),
80
80
  pool.query('SELECT goal_id, note, added_at, added_by FROM autonomy_allowed_goals ORDER BY added_at DESC'),
81
81
  ]);
82
82
  const row = fence.rows[0];
@@ -84,6 +84,9 @@ async function readFence() {
84
84
  return {
85
85
  enabled: row.enabled === true,
86
86
  paused_reason: row.paused_reason,
87
+ // A bigint, so a string from node-postgres; normalised for the same reason as
88
+ // the goal ids below. null = no priority (task 1004453).
89
+ priority_goal_id: row.priority_goal_id == null ? null : Number(row.priority_goal_id),
87
90
  updated_at: row.updated_at,
88
91
  updated_by: row.updated_by ? String(row.updated_by) : null,
89
92
  // goal_id is a bigint, which node-postgres hands back as a STRING. The runner
@@ -124,6 +127,28 @@ async function allowGoal({ goalId, note = null, builderId = null }) {
124
127
  return r ? { ...r, goal_id: Number(r.goal_id) } : null;
125
128
  }
126
129
 
130
+ // setPriorityGoal — name the goal the runner works first, or clear it with null
131
+ // (task 1004453). The WHERE refuses a goal that is not allowlisted, so the write
132
+ // either lands or returns nothing and the route can say why; the foreign key from
133
+ // autonomy_003 backs the same rule in the schema. Returns:
134
+ // { row } — written
135
+ // { notAllowed } — that goal is not on the allowlist
136
+ // { missing } — the singleton row is gone (migrations not run)
137
+ async function setPriorityGoal({ goalId, builderId = null }) {
138
+ const { rows } = await pool.query(
139
+ `UPDATE autonomy_fence
140
+ SET priority_goal_id = $1, updated_at = now(), updated_by = $2
141
+ WHERE id = 1
142
+ AND ($1::bigint IS NULL OR EXISTS (SELECT 1 FROM autonomy_allowed_goals WHERE goal_id = $1::bigint))
143
+ RETURNING priority_goal_id, updated_at`,
144
+ [goalId, builderId]
145
+ );
146
+ const r = rows[0];
147
+ if (r) return { row: { priority_goal_id: r.priority_goal_id == null ? null : Number(r.priority_goal_id), updated_at: r.updated_at } };
148
+ const { rows: fenceRows } = await pool.query('SELECT 1 FROM autonomy_fence WHERE id = 1');
149
+ return fenceRows.length ? { notAllowed: true } : { missing: true };
150
+ }
151
+
127
152
  // disallowGoal — take a goal off the allowlist. `removed` says whether a row
128
153
  // actually went, because "already gone" and "just revoked" are different answers
129
154
  // to an owner clicking Remove.
@@ -137,11 +162,11 @@ async function disallowGoal(goalId) {
137
162
  // recordRunnerHeartbeat — upsert one host's check-in. ON CONFLICT so the normal
138
163
  // case is one write, and `host` is the key so a second runner appears as a second
139
164
  // row rather than silently overwriting the first.
140
- async function recordRunnerHeartbeat({ builderId, host, pid = null, startedAt = null, mode = null, consecutiveFailures = null, lastEvent = null, workingTaskId = null }) {
165
+ async function recordRunnerHeartbeat({ builderId, host, pid = null, startedAt = null, mode = null, consecutiveFailures = null, lastEvent = null, workingTaskId = null, workingGoalId = null }) {
141
166
  const { rows } = await pool.query(
142
167
  `INSERT INTO autonomy_runner_heartbeat
143
- (builder_id, host, pid, started_at, mode, consecutive_failures, last_event, working_task_id, last_seen_at, updated_at)
144
- VALUES ($8, $1, $2, $3, $4, $5, $6, $7, now(), now())
168
+ (builder_id, host, pid, started_at, mode, consecutive_failures, last_event, working_task_id, working_goal_id, last_seen_at, updated_at)
169
+ VALUES ($8, $1, $2, $3, $4, $5, $6, $7, $9, now(), now())
145
170
  ON CONFLICT (builder_id, host) DO UPDATE SET
146
171
  pid = EXCLUDED.pid,
147
172
  started_at = COALESCE(EXCLUDED.started_at, autonomy_runner_heartbeat.started_at),
@@ -149,10 +174,11 @@ async function recordRunnerHeartbeat({ builderId, host, pid = null, startedAt =
149
174
  consecutive_failures = EXCLUDED.consecutive_failures,
150
175
  last_event = EXCLUDED.last_event,
151
176
  working_task_id = EXCLUDED.working_task_id,
177
+ working_goal_id = EXCLUDED.working_goal_id,
152
178
  last_seen_at = now(),
153
179
  updated_at = now()
154
- RETURNING host, pid, started_at, mode, consecutive_failures, last_event, working_task_id, last_seen_at`,
155
- [String(host).toLowerCase().slice(0, 255), pid, startedAt, mode, consecutiveFailures, lastEvent, workingTaskId, builderId]
180
+ RETURNING host, pid, started_at, mode, consecutive_failures, last_event, working_task_id, working_goal_id, last_seen_at`,
181
+ [String(host).toLowerCase().slice(0, 255), pid, startedAt, mode, consecutiveFailures, lastEvent, workingTaskId, builderId, workingGoalId]
156
182
  );
157
183
  return rows[0] || null;
158
184
  }
@@ -167,14 +193,18 @@ async function recordRunnerHeartbeat({ builderId, host, pid = null, startedAt =
167
193
  // liveness view entirely (grader finding, task 1003905).
168
194
  async function readRunnerHeartbeats(builderId) {
169
195
  const { rows } = await pool.query(
170
- `SELECT host, pid, started_at, mode, consecutive_failures, last_event, working_task_id, last_seen_at
196
+ `SELECT host, pid, started_at, mode, consecutive_failures, last_event, working_task_id, working_goal_id, last_seen_at
171
197
  FROM autonomy_runner_heartbeat
172
198
  WHERE builder_id = $1
173
199
  ORDER BY last_seen_at DESC
174
200
  LIMIT 20`,
175
201
  [builderId]
176
202
  );
177
- return rows.map((r) => ({ ...r, working_task_id: r.working_task_id ? String(r.working_task_id) : null }));
203
+ return rows.map((r) => ({
204
+ ...r,
205
+ working_task_id: r.working_task_id ? String(r.working_task_id) : null,
206
+ working_goal_id: r.working_goal_id ? String(r.working_goal_id) : null,
207
+ }));
178
208
  }
179
209
 
180
210
  module.exports = {
@@ -187,4 +217,5 @@ module.exports = {
187
217
  setFenceEnabled,
188
218
  allowGoal,
189
219
  disallowGoal,
220
+ setPriorityGoal,
190
221
  };
@@ -74,25 +74,44 @@ function normaliseFence(raw) {
74
74
  const n = Number(typeof g === 'object' && g !== null ? g.goal_id : g);
75
75
  if (Number.isSafeInteger(n) && n > 0) ids.push(n);
76
76
  }
77
+ // The priority goal (task 1004453). Honoured only when it is ON the allowlist:
78
+ // the database already enforces that with a foreign key, and this re-checks it
79
+ // so a priority can only ever ORDER the allowlist, never add to it. Anything
80
+ // else (missing, malformed, not allowlisted) reads as "no priority", which is
81
+ // the old behaviour, not a refusal: the allowlist alone is still a safe answer.
82
+ const p = Number(raw.priority_goal_id);
83
+ const priorityGoal = raw.priority_goal_id != null && Number.isSafeInteger(p) && p > 0 && ids.includes(p) ? p : null;
77
84
  return {
78
85
  enabled: raw.enabled === true,
79
86
  pausedReason: typeof raw.paused_reason === 'string' ? raw.paused_reason : null,
80
87
  goals: ids,
88
+ priorityGoal,
81
89
  };
82
90
  }
83
91
 
92
+ // prioritise — the same goals with the priority goal moved to the front. A goal
93
+ // that is not in the list is ignored, so this can reorder and never add.
94
+ function prioritise(goals, priorityGoal) {
95
+ if (priorityGoal == null || !goals.includes(priorityGoal)) return goals.slice();
96
+ return [priorityGoal, ...goals.filter((g) => g !== priorityGoal)];
97
+ }
98
+
84
99
  // allowedGoals — the goals this run may work, given the fence and whatever the
85
100
  // operator asked for on the command line.
86
101
  //
87
102
  // The INTERSECTION is the point. `requested` can only narrow: a goal the operator
88
103
  // named that the database does not allow is dropped, and reported, rather than
89
104
  // honoured. The runner host is not a place authority can come from.
105
+ //
106
+ // The priority goal goes FIRST, after the intersection, so an operator narrowing
107
+ // to goals that leave it out simply gets no priority rather than having it
108
+ // re-added.
90
109
  function allowedGoals(fence, requested = []) {
91
110
  const allowed = new Set(fence.goals);
92
111
  const asked = (requested || []).map((g) => Number(g)).filter((n) => Number.isSafeInteger(n) && n > 0);
93
- if (!asked.length) return { goals: fence.goals.slice(), refused: [] };
112
+ if (!asked.length) return { goals: prioritise(fence.goals, fence.priorityGoal), refused: [] };
94
113
  return {
95
- goals: asked.filter((g) => allowed.has(g)),
114
+ goals: prioritise(asked.filter((g) => allowed.has(g)), fence.priorityGoal),
96
115
  refused: asked.filter((g) => !allowed.has(g)),
97
116
  };
98
117
  }
@@ -137,7 +156,10 @@ function decideRun({ fence: rawFence, requestedGoals = [], graderBypassed = null
137
156
  : REFUSALS.no_allowlist,
138
157
  };
139
158
  }
140
- return { go: true, goals, refusedGoals: refused, code: null, reason: null };
159
+ // priorityGoal is reported only when it survived the intersection, so a row can
160
+ // say "this pick came from the priority goal" without re-deriving the rule.
161
+ const priorityGoal = fence.priorityGoal != null && goals[0] === fence.priorityGoal ? fence.priorityGoal : null;
162
+ return { go: true, goals, priorityGoal, refusedGoals: refused, code: null, reason: null };
141
163
  }
142
164
 
143
165
  // decideTask — may THIS task be handed to an unattended worker?
@@ -169,4 +191,4 @@ function decideTask({ task, goals = [], protectedHits = [] } = {}) {
169
191
  return { go: true, code: null, reason: null };
170
192
  }
171
193
 
172
- module.exports = { REFUSALS, normaliseFence, allowedGoals, decideRun, decideTask };
194
+ module.exports = { REFUSALS, normaliseFence, prioritise, allowedGoals, decideRun, decideTask };
@@ -0,0 +1,32 @@
1
+ -- autonomy_003_priority_goal.sql — the fence's priority goal (task 1004453).
2
+ --
3
+ -- Owner ruling, 2026-09-30: whoever starts the runner says which goal comes FIRST
4
+ -- as well as which goals it may touch at all. The priority sits on the fence row,
5
+ -- next to the kill switch, for the same reason the allowlist does: the runner
6
+ -- host can read it and cannot write it (see autonomy_001's header).
7
+ --
8
+ -- A FOREIGN KEY TO THE ALLOWLIST, deliberately. The priority goal must be an
9
+ -- allowlisted goal; the route checks that, and this makes it a fact the database
10
+ -- holds rather than a rule one route remembers. ON DELETE SET NULL is the safe
11
+ -- direction: revoking the priority goal from the allowlist clears the priority,
12
+ -- so a revoked goal can never be worked because it was once "first".
13
+ --
14
+ -- The priority ORDERS the allowlist; it never widens it. NULL (the default) means
15
+ -- no priority, which is today's behaviour.
16
+ --
17
+ -- working_goal_id on the heartbeat is the other half of the done-when: the hall
18
+ -- can say which goal the runner's current task came from. Nullable, no FK — a
19
+ -- heartbeat is a report, and a report about a goal deleted since is still true.
20
+ --
21
+ -- Additive and idempotent (ADR 0083 §Decision #5): safe to re-run.
22
+
23
+ BEGIN;
24
+
25
+ ALTER TABLE autonomy_fence
26
+ ADD COLUMN IF NOT EXISTS priority_goal_id bigint
27
+ REFERENCES autonomy_allowed_goals (goal_id) ON DELETE SET NULL;
28
+
29
+ ALTER TABLE autonomy_runner_heartbeat
30
+ ADD COLUMN IF NOT EXISTS working_goal_id bigint;
31
+
32
+ COMMIT;
@@ -88,7 +88,7 @@ module.exports = function buildAutonomyRouter() {
88
88
 
89
89
  // ── the Autobongos fence (task 1003908, ADR 0307 §6) ───────────────────────
90
90
  //
91
- // These four routes ARE the fence. Everything the unattended runner is allowed
91
+ // These five routes ARE the fence. Everything the unattended runner is allowed
92
92
  // to do is decided by rows only these can write, on a server the runner host
93
93
  // cannot reach — that separation is the entire safety argument, so read the
94
94
  // rank gates below as load-bearing rather than boilerplate.
@@ -172,6 +172,31 @@ module.exports = function buildAutonomyRouter() {
172
172
  }
173
173
  });
174
174
 
175
+ // The priority goal (task 1004453): which allowlisted goal the runner works
176
+ // first. Same gate as the allowlist — choosing what an unattended machine does
177
+ // first is part of choosing what it may do. The goal must already be on the
178
+ // allowlist (409 otherwise), so a priority can order the list and never widen
179
+ // it. `goal_id: null` clears it.
180
+ router.post('/autonomy/fence/priority', auth.requireBuilder, auth.requirePermission('autonomy.fence.manage'), async (req, res) => {
181
+ // Declared with an open rule: the value is a number OR null, which the
182
+ // validator has no type for, so the shape is checked just below.
183
+ if (validateOrRespond(req, res, { goal_id: {} })) return;
184
+ const raw = req.body ? req.body.goal_id : undefined;
185
+ if (raw === undefined) return res.fail('goal_id_required', { status: 400, message: 'pass { "goal_id": <number> } or { "goal_id": null } to clear' });
186
+ const goalId = raw === null ? null : Number(raw);
187
+ if (goalId !== null && (!Number.isSafeInteger(goalId) || goalId <= 0)) return res.fail('goal_id_required', { status: 400, message: 'pass { "goal_id": <number> } or { "goal_id": null } to clear' });
188
+ try {
189
+ const out = await db.setPriorityGoal({ goalId, builderId: req.builder.id });
190
+ if (out.missing) return res.fail('fence_uninitialised', { status: 503, message: 'the autonomy fence row is missing — run the module migrations' });
191
+ if (out.notAllowed) return res.fail('goal_not_allowlisted', { status: 409, message: `goal ${goalId} is not on the allowlist — add it first` });
192
+ log.info(`[gds] autonomy fence: priority goal ${goalId === null ? 'CLEARED' : `set to ${goalId}`} by builder ${req.builder.id} (${req.builder.rank})`);
193
+ res.json(out.row);
194
+ } catch (err) {
195
+ log.error('[gds] POST /autonomy/fence/priority', err);
196
+ res.fail('priority_failed', { status: 500, message: 'internal error' });
197
+ }
198
+ });
199
+
175
200
  // ── the runner heartbeat (task 1003905) ────────────────────────────────────
176
201
  //
177
202
  // A dead runner must be VISIBLE, not silent. The runner also writes a heartbeat
@@ -193,6 +218,7 @@ module.exports = function buildAutonomyRouter() {
193
218
  consecutive_failures: { type: 'number' },
194
219
  last_event: { type: 'string', maxLength: 64 },
195
220
  working_task_id: { type: 'number' },
221
+ working_goal_id: { type: 'number' },
196
222
  })) return;
197
223
  const b = req.body || {};
198
224
  try {
@@ -207,6 +233,7 @@ module.exports = function buildAutonomyRouter() {
207
233
  consecutiveFailures: Number.isInteger(b.consecutive_failures) ? b.consecutive_failures : null,
208
234
  lastEvent: b.last_event || null,
209
235
  workingTaskId: Number.isInteger(b.working_task_id) ? b.working_task_id : null,
236
+ workingGoalId: Number.isInteger(b.working_goal_id) ? b.working_goal_id : null,
210
237
  });
211
238
  res.json({ ok: true, heartbeat: row });
212
239
  } catch (err) {
@@ -275,16 +275,31 @@
275
275
  });
276
276
  }
277
277
 
278
- function renderFenceGoals(goals) {
278
+ // The priority goal (task 1004453) is one of the allowlisted rows, marked and
279
+ // listed first, because that is the order the runner works them in. Its button
280
+ // clears it; every other row's button makes that goal the priority.
281
+ function renderFenceGoals(goals, priorityGoalId) {
279
282
  const el = document.getElementById('fence-goals');
280
- const rows = (goals || []).map((g) => `
283
+ const pri = priorityGoalId == null ? null : Number(priorityGoalId);
284
+ // true - false = 1, so the priority row sorts ahead of every other; the rest keep their order.
285
+ const ordered = (goals || []).slice().sort((a, b) => (Number(b.goal_id) === pri) - (Number(a.goal_id) === pri));
286
+ const rows = ordered.map((g) => {
287
+ const isPri = Number(g.goal_id) === pri;
288
+ return `
281
289
  <li class="ov-row" style="display:flex;align-items:center;gap:12px;">
282
290
  <a href="/builders#/goal/${encodeURIComponent(g.goal_id)}">Goal ${escapeHtml(String(g.goal_id))}</a>
291
+ ${isPri ? '<span class="fact-pill fact-pill--ok">Priority</span>' : ''}
283
292
  <span style="flex:1;">${escapeHtml(g.note || '')}</span>
293
+ <button type="button" class="btn" data-priority="${isPri ? '' : escapeHtml(String(g.goal_id))}">${isPri ? 'Clear priority' : 'Make priority'}</button>
284
294
  <button type="button" class="btn" data-revoke="${escapeHtml(String(g.goal_id))}">Remove</button>
285
- </li>`).join('');
295
+ </li>`;
296
+ }).join('');
297
+ const priLine = pri === null
298
+ ? 'No priority goal: the runner works these in any order.'
299
+ : `The runner works goal ${escapeHtml(String(pri))} first, then the others once it has nothing left to claim there.`;
286
300
  el.innerHTML = `
287
301
  <h3 class="scroll__h" style="font-size:1rem;">Allowlisted goals</h3>
302
+ ${goals && goals.length ? `<p class="ov-foot">${priLine}</p>` : ''}
288
303
  ${goals && goals.length ? `<ul class="ov-rows">${rows}</ul>`
289
304
  : emptyStateHtml('No goal is allowlisted, so the runner has nothing it may work on.')}
290
305
  <form id="fence-add" class="ov-foot" style="display:flex;gap:8px;align-items:center;margin-top:12px;">
@@ -305,6 +320,19 @@
305
320
  } catch (err) { toast(err.message || 'Could not add that goal.'); }
306
321
  });
307
322
 
323
+ for (const btn of el.querySelectorAll('[data-priority]')) {
324
+ btn.addEventListener('click', async () => {
325
+ // An empty attribute is the Clear button on the current priority row.
326
+ const v = btn.getAttribute('data-priority');
327
+ const goalId = v ? Number(v) : null;
328
+ try {
329
+ await sendJson('POST', '/autonomy/fence/priority', { goal_id: goalId });
330
+ toast(goalId === null ? 'Priority cleared.' : `Goal ${goalId} is now the priority.`);
331
+ await loadFence();
332
+ } catch (err) { toast(err.message || 'Could not change the priority.'); }
333
+ });
334
+ }
335
+
308
336
  for (const btn of el.querySelectorAll('[data-revoke]')) {
309
337
  btn.addEventListener('click', async () => {
310
338
  const goalId = btn.getAttribute('data-revoke');
@@ -360,6 +388,7 @@
360
388
  return `<p class="ov-foot" style="display:flex;align-items:baseline;gap:10px;flex-wrap:wrap;">
361
389
  <span class="fact-pill ${cls}">${escapeHtml(label)}</span>
362
390
  <span>${escapeHtml(h.detail || '')}</span>
391
+ ${r.working_goal_id ? `<span class="ov-fact">from goal ${escapeHtml(String(r.working_goal_id))}</span>` : ''}
363
392
  <span class="ov-fact">last check-in ${escapeHtml(h.age || 'unknown')} ago</span>
364
393
  </p>`;
365
394
  }).join('');
@@ -379,7 +408,7 @@
379
408
  return;
380
409
  }
381
410
  renderFenceState(fence);
382
- renderFenceGoals(fence.goals);
411
+ renderFenceGoals(fence.goals, fence.priority_goal_id);
383
412
  }
384
413
 
385
414
  async function init() {
@@ -95,9 +95,16 @@ const NEVER_ROUTE_GRADE = 'full';
95
95
  // 'discord-bugs' — the #bugs channel, where the CHANNEL is the classification
96
96
  // (modules/discord/discord-bugs.js forces kind='bug'), and
97
97
  // which already carries a rate cap + an unlinked-author guard.
98
+ // 'instance-feedback' — POST /sso/feedback on the hub (task 1004462): a
99
+ // federated project relaying `bongos feedback` about Bongos
100
+ // itself. It earns the vector the way #bugs does — the
101
+ // sender must be a hub-witnessed member of the calling
102
+ // project, and the route caps filings per sender and per
103
+ // project — and it always files with NO rank, so every
104
+ // report lands at 'backlog' awaiting a nod.
98
105
  // 'discord-ideas' is deliberately ABSENT: it is an ideation channel, and the bug
99
106
  // reports that belong to Discord already have #bugs.
100
- const HOMELESS_ROUTING_VECTORS = new Set(['api', 'discord-bugs']);
107
+ const HOMELESS_ROUTING_VECTORS = new Set(['api', 'discord-bugs', 'instance-feedback']);
101
108
 
102
109
  // decideLanding — the matrix, as one pure function.
103
110
  //
@@ -0,0 +1,142 @@
1
+ 'use strict';
2
+
3
+ // modules/platform-identity/instance-feedback.js — feedback about Bongos ITSELF,
4
+ // relayed up from a federated project to the hub (task 1004462).
5
+ //
6
+ // A builder on a hosted project runs `bongos feedback "…"`. Their OWN instance
7
+ // stamps who sent it and which core it runs, and relays it here over the
8
+ // hub-federation client credential (ADR 0141) — the same server-to-server channel
9
+ // as the activity rollup. This file turns that relay into a filing on the hub:
10
+ //
11
+ // kind 'bug' → a task in the building version's MAINTENANCE goal, at 'backlog'
12
+ // (ADR 0262's homeless-bug home, reached through the
13
+ // 'instance-feedback' routing vector).
14
+ // kind 'idea' → an idea_inbox row for /idea-triage, exactly as any goal-less
15
+ // idea lands — a suggestion is not a defect, and the maintenance
16
+ // goal is for defects.
17
+ //
18
+ // ALWAYS 'backlog', never 'ready' — the owner's decision (2026-09-30). Anyone on
19
+ // any tenant project can send this, so an approval stands between a report and
20
+ // paid agent work. That is enforced by passing NO filer rank: decideLanding reads
21
+ // an absent rank as untrusted, so even the owner's own feedback waits for the nod
22
+ // rather than inheriting a hub rank the instance never vouched for.
23
+ //
24
+ // THE ATTRIBUTION IS THE HUB'S, NOT THE INSTANCE'S. Client credentials prove WHICH
25
+ // PROJECT is calling and nothing about WHO sent it, so the sender must hold a
26
+ // membership on that project carrying the hub's own sign-in witness
27
+ // (membershipBacksRollup — the ADR 0205 rule the rollup uses). The login printed
28
+ // on the task is then read from the hub's account row, never from the body.
29
+
30
+ const FEEDBACK_KINDS = Object.freeze({ bug: 'bug', idea: 'feature' });
31
+ const MESSAGE_MAX = 4000;
32
+ const TITLE_MAX = 120;
33
+
34
+ // Per-hour caps, counted in memory on this hub process. Two, because they stop
35
+ // different things: one noisy builder, and one project relaying for many.
36
+ const FEEDBACK_WINDOW_MS = 60 * 60 * 1000;
37
+ const PER_SENDER_LIMIT = 10;
38
+ const PER_PROJECT_LIMIT = 30;
39
+
40
+ // makeFeedbackLimiter — a sliding-window counter keyed by project and sender.
41
+ // `take` records a filing only when BOTH caps have room, so a refusal on one cap
42
+ // never spends the other.
43
+ function makeFeedbackLimiter({ now = Date.now } = {}) {
44
+ const buckets = new Map(); // key -> timestamp[]
45
+ function prune(key, t) {
46
+ const arr = buckets.get(key);
47
+ if (!arr) return [];
48
+ while (arr.length && arr[0] <= t - FEEDBACK_WINDOW_MS) arr.shift();
49
+ if (arr.length === 0) buckets.delete(key);
50
+ return arr;
51
+ }
52
+ function take(clientId, githubId) {
53
+ const t = now();
54
+ const checks = [
55
+ [`p:${clientId}`, PER_PROJECT_LIMIT, 'project'],
56
+ [`s:${clientId}:${githubId}`, PER_SENDER_LIMIT, 'sender'],
57
+ ];
58
+ for (const [key, limit, scope] of checks) {
59
+ const arr = prune(key, t);
60
+ if (arr.length >= limit) {
61
+ const retryAfter = Math.max(1, Math.ceil((arr[0] + FEEDBACK_WINDOW_MS - t) / 1000));
62
+ return { ok: false, scope, retryAfter };
63
+ }
64
+ }
65
+ for (const [key] of checks) {
66
+ if (!buckets.has(key)) buckets.set(key, []);
67
+ buckets.get(key).push(t);
68
+ }
69
+ return { ok: true };
70
+ }
71
+ return { take, _buckets: buckets };
72
+ }
73
+
74
+ // A version stamp from the instance: short, and only the characters a version
75
+ // string or an OS name uses, so it cannot carry markdown or links into the task.
76
+ function cleanStamp(v) {
77
+ if (typeof v !== 'string') return null;
78
+ const s = v.trim().slice(0, 40);
79
+ return /^[\w.+\- ]+$/.test(s) ? s : null;
80
+ }
81
+
82
+ // The task title: the first non-empty line, trimmed to fit.
83
+ function feedbackTitle(message) {
84
+ const first = String(message).split('\n').map((l) => l.trim()).find(Boolean) || 'Feedback';
85
+ const title = first.length > TITLE_MAX ? `${first.slice(0, TITLE_MAX - 1)}…` : first;
86
+ return `Feedback: ${title}`;
87
+ }
88
+
89
+ function feedbackBody({ message, client, account, githubId, context = {} }) {
90
+ const who = account && account.github_login ? `@${account.github_login}` : `GitHub user ${githubId}`;
91
+ const project = client.name ? `${client.name} (${client.origin || client.client_id})` : (client.origin || client.client_id);
92
+ const stamps = [
93
+ ['Core', cleanStamp(context.core_version)],
94
+ ['CLI', cleanStamp(context.cli_version)],
95
+ ['OS', cleanStamp(context.os)],
96
+ ].filter(([, v]) => v).map(([k, v]) => `${k} ${v}`).join(' · ');
97
+ // The hub's own attribution comes FIRST and the sender's words follow as a
98
+ // quote, so text they type can never pose as the footer a reviewer trusts.
99
+ const quoted = String(message).trim().split('\n').map((l) => `> ${l}`).join('\n');
100
+ return [
101
+ 'Sent from a managed project with `bongos feedback`.',
102
+ `- Project: ${project}`,
103
+ `- Sent by: ${who}`,
104
+ ...(stamps ? [`- Running: ${stamps}`] : []),
105
+ '',
106
+ quoted,
107
+ ].join('\n');
108
+ }
109
+
110
+ // fileInstanceFeedback — file one relayed report. `deps.capture` is the
111
+ // `ideas.capture` port; `deps.getAccount` and `deps.hubBuilderId` read the hub's
112
+ // own rows for the sender. Returns the landing the CLI prints.
113
+ async function fileInstanceFeedback({ client, githubId, message, kind, context }, deps) {
114
+ const account = await deps.getAccount(githubId).catch(() => null);
115
+ const capturedBy = await deps.hubBuilderId(githubId).catch(() => null);
116
+ const idea = await deps.capture({
117
+ title: feedbackTitle(message),
118
+ bodyMd: feedbackBody({ message, client, account, githubId, context }),
119
+ kind: FEEDBACK_KINDS[kind],
120
+ capturedBy,
121
+ filerRank: null, // untrusted by design — see the header
122
+ filedVia: 'api',
123
+ vector: 'instance-feedback',
124
+ suggestedPriority: 3,
125
+ });
126
+ const routing = (idea && idea.routing) || {};
127
+ return routing.routed
128
+ ? { landed: 'task', task_id: String(routing.task_id), goal_id: String(routing.goal_id), status: routing.status, idea_id: String(idea.id) }
129
+ : { landed: 'inbox', idea_id: String(idea.id) };
130
+ }
131
+
132
+ module.exports = {
133
+ FEEDBACK_KINDS,
134
+ MESSAGE_MAX,
135
+ PER_SENDER_LIMIT,
136
+ PER_PROJECT_LIMIT,
137
+ FEEDBACK_WINDOW_MS,
138
+ makeFeedbackLimiter,
139
+ feedbackTitle,
140
+ feedbackBody,
141
+ fileInstanceFeedback,
142
+ };
@@ -16,5 +16,5 @@
16
16
  "pollers": ["code-sweep", "visibility-pull", "guild-totals"]
17
17
  },
18
18
  "provides": ["platform-identity.hub", "project.publishGate", "project.catalog"],
19
- "consumes": []
19
+ "consumes": ["ideas.capture"]
20
20
  }