@bongos/core 1.18.19 → 1.19.2

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 (251) hide show
  1. package/.bongos-core.json +514 -194
  2. package/.claude/skills/builder-box/SKILL.md +1 -1
  3. package/.claude/skills/builder-claim/SKILL.md +1 -1
  4. package/.claude/skills/goal-create/SKILL.md +1 -1
  5. package/.claude/skills/idea-triage/SKILL.md +1 -1
  6. package/.claude/skills/planning-session/SKILL.md +5 -5
  7. package/.claude/skills/scan-before-install/SKILL.md +165 -0
  8. package/clients/bongos-client/README.md +1 -1
  9. package/clients/bongos-client/bongos-client.global.js +63 -41
  10. package/clients/bongos-client/index.cjs +63 -41
  11. package/clients/bongos-client/index.d.ts +78 -44
  12. package/clients/bongos-client/index.mjs +63 -41
  13. package/docs/adr/0020-security-model-public-stub.md +1 -1
  14. package/docs/adr/0097-one-active-claim-per-session-and-worktree-binding.md +2 -0
  15. package/docs/adr/0144-devbox-rehome-onto-cloudbongos-plane.md +1 -1
  16. package/docs/adr/0151-governance-permissions-as-atom-ranks-as-roles.md +2 -1
  17. package/docs/adr/0155-adopt-private-repo-widen-oauth-scope.md +51 -0
  18. package/docs/adr/0156-user-set-display-name-wins.md +62 -0
  19. package/docs/adr/0157-archon-is-rank-and-identity-only.md +93 -0
  20. package/docs/adr/0158-choke-point-verdict-taxonomy.md +174 -0
  21. package/docs/adr/0159-merge-gate-reconciliation.md +88 -0
  22. package/docs/adr/0160-main-audit-repo-verifiable-caller.md +77 -0
  23. package/docs/adr/README.md +8 -0
  24. package/docs/api/openapi.json +1209 -233
  25. package/docs/api-reference.md +61 -46
  26. package/docs/architecture.md +1 -1
  27. package/docs/canonical-permissions.md +61 -16
  28. package/docs/design/vanilla-hall-ui-redesign-scope.md +1 -1
  29. package/docs/file-map.md +4 -1
  30. package/docs/onboarding/diagrams/assertions.json +3 -3
  31. package/docs/recipes/core-release-pipeline.md +179 -0
  32. package/docs/recipes/packaging-the-core.md +2 -0
  33. package/docs/recipes/parallel-goal-waves.md +1 -3
  34. package/docs/recipes/private-npm-distribution.md +2 -0
  35. package/docs/recipes/upgrading-the-core.md +2 -0
  36. package/migrations/core_197_display_name_source.sql +56 -0
  37. package/migrations/core_204_rekey_active_claim_per_session_worktree.sql +77 -0
  38. package/modules/autonomy/routes/autonomy.js +1 -1
  39. package/modules/builder-settings/db.js +7 -1
  40. package/modules/dev-box/app/src/vendor/bongos-client.cjs +63 -41
  41. package/modules/dev-box/routes/box.js +11 -9
  42. package/modules/discord/auth.js +3 -1
  43. package/modules/discord/routes/discord.js +8 -2
  44. package/modules/discord/ship-broadcast.js +48 -1
  45. package/modules/governance/catalog.js +70 -20
  46. package/modules/governance/db.js +102 -0
  47. package/modules/governance/migrations/governance_006_version_create.sql +38 -0
  48. package/modules/governance/migrations/governance_007_delegate_archon_ops_to_metic.sql +72 -0
  49. package/modules/governance/routes/governance.js +235 -6
  50. package/modules/grading/grader-workers/index.js +18 -9
  51. package/modules/hall-ui/public/gate.js +6 -2
  52. package/modules/hall-ui/public/goals-page.js +6 -2
  53. package/modules/hall-ui/public/governance.css +100 -0
  54. package/modules/hall-ui/public/governance.html +77 -0
  55. package/modules/hall-ui/public/governance.js +353 -0
  56. package/modules/hall-ui/public/harbor.js +14 -8
  57. package/modules/hall-ui/public/instances-panel.js +3 -1
  58. package/modules/hall-ui/public/new-project-wizard.js +6 -0
  59. package/modules/hall-ui/public/people.html +51 -0
  60. package/modules/hall-ui/public/people.js +100 -0
  61. package/modules/hall-ui/public/profile.html +2 -1
  62. package/modules/hall-ui/public/profile.js +9 -3
  63. package/modules/hall-ui/public/sessions.js +4 -2
  64. package/modules/hall-ui/public/shell.js +28 -7
  65. package/modules/hall-ui/public/task-detail.js +6 -3
  66. package/modules/hall-ui/public/watch.js +17 -9
  67. package/modules/ideas/blockers.js +44 -7
  68. package/modules/ideas/routes/blockers.js +12 -7
  69. package/modules/ideas/routes/inbox.js +2 -2
  70. package/modules/lifecycle/db.js +84 -17
  71. package/modules/lifecycle/github-push.js +37 -10
  72. package/modules/lifecycle/publish-reconciler.js +128 -31
  73. package/modules/lifecycle/routes/claims.js +10 -4
  74. package/modules/lifecycle/routes/dependencies.js +2 -2
  75. package/modules/lifecycle/routes/done-when.js +4 -4
  76. package/modules/lifecycle/routes/gate-approvals.js +9 -3
  77. package/modules/lifecycle/routes/goals.js +5 -5
  78. package/modules/lifecycle/routes/tasks.js +16 -16
  79. package/modules/lifecycle/routes/versions.js +13 -2
  80. package/modules/lifecycle/ship-card.js +38 -7
  81. package/modules/memory/routes/memory.js +2 -2
  82. package/modules/onboarding/routes/access-requests.js +3 -3
  83. package/modules/platform-identity/routes/projects.js +10 -6
  84. package/modules/platform-identity/routes/scouting.js +2 -2
  85. package/modules/provisioning/migrations/provisioning_004_onboard_mode.sql +30 -0
  86. package/modules/provisioning/provisioning.js +24 -6
  87. package/modules/provisioning/routes/provisioning.js +24 -6
  88. package/modules/security/artifact-scan/aggregate.js +133 -0
  89. package/modules/security/artifact-scan/detectors/capabilities.js +53 -0
  90. package/modules/security/artifact-scan/detectors/config-obfuscation.js +41 -0
  91. package/modules/security/artifact-scan/detectors/credentials.js +112 -0
  92. package/modules/security/artifact-scan/detectors/dependencies.js +115 -0
  93. package/modules/security/artifact-scan/detectors/hooks.js +116 -0
  94. package/modules/security/artifact-scan/detectors/injection.js +118 -0
  95. package/modules/security/artifact-scan/detectors/mcp.js +102 -0
  96. package/modules/security/artifact-scan/detectors/network.js +67 -0
  97. package/modules/security/artifact-scan/detectors/npm-lifecycle.js +67 -0
  98. package/modules/security/artifact-scan/detectors/permissions.js +91 -0
  99. package/modules/security/artifact-scan/detectors/shared.js +219 -0
  100. package/modules/security/artifact-scan/fence-local.js +99 -0
  101. package/modules/security/artifact-scan/fetch.js +330 -0
  102. package/modules/security/artifact-scan/floor.js +319 -0
  103. package/modules/security/artifact-scan/limits.js +66 -0
  104. package/modules/security/artifact-scan/manifest.js +203 -0
  105. package/modules/security/artifact-scan/mirror.js +158 -0
  106. package/modules/security/artifact-scan/quarantine.js +149 -0
  107. package/modules/security/artifact-scan/rebuild-spec.js +120 -0
  108. package/modules/security/artifact-scan/report.js +155 -0
  109. package/modules/security/artifact-scan/sanitize.js +317 -0
  110. package/modules/security/migrations/security_001_tool_scans.sql +145 -0
  111. package/modules/security/module.json +2 -2
  112. package/modules/security/routes/reports.js +11 -7
  113. package/modules/security/routes/scans.js +115 -0
  114. package/modules/security/scan-store.js +214 -0
  115. package/modules/security/tests/aggregate.mjs +283 -0
  116. package/modules/security/tests/artifact_scan.mjs +591 -0
  117. package/modules/security/tests/floor.mjs +549 -0
  118. package/modules/security/tests/mirror.mjs +350 -0
  119. package/modules/security/tests/sanitize_rebuild.mjs +499 -0
  120. package/modules/security/tests/scan_store.mjs +250 -0
  121. package/modules/security/tests/tool_scans_migration.mjs +116 -0
  122. package/modules/sessions/routes/sessions.js +6 -5
  123. package/package-lock.json +2 -2
  124. package/package.json +1 -1
  125. package/public-landing/create.html +13 -3
  126. package/scripts/gds/abandon.js +1 -1
  127. package/scripts/gds/audit-authorship.sh +6 -0
  128. package/scripts/gds/box.js +1 -1
  129. package/scripts/gds/claim.js +15 -0
  130. package/scripts/gds/cli-lib.js +16 -4
  131. package/scripts/gds/gating.js +28 -13
  132. package/scripts/gds/gen-api-docs.js +1 -1
  133. package/scripts/gds/goal.js +234 -0
  134. package/scripts/gds/init.js +70 -15
  135. package/scripts/gds/main-audit.js +80 -11
  136. package/scripts/gds/mirror-redact.js +6 -0
  137. package/scripts/gds/newcomer-floor.js +2 -2
  138. package/scripts/gds/provision.js +230 -94
  139. package/scripts/gds/run-unit-tests.js +9 -1
  140. package/scripts/gds/sandbox-stage.js +66 -9
  141. package/scripts/gds/search-ingest-md.js +1 -0
  142. package/scripts/gds/sequence.js +67 -8
  143. package/scripts/gds/ship.js +183 -50
  144. package/scripts/hall-preview/server.js +1 -1
  145. package/scripts/migrate.sh +5 -5
  146. package/src/bongos/auth.js +129 -27
  147. package/src/bongos/db-kernel.js +66 -5
  148. package/src/bongos/db.js +6 -7
  149. package/src/bongos/github-repos.js +19 -7
  150. package/src/bongos/goal-scope-check.js +1 -1
  151. package/src/bongos/module-scope-map.js +3 -1
  152. package/src/bongos/permission-path-check.js +1 -1
  153. package/src/bongos/route-rank-check.js +73 -30
  154. package/src/bongos/routes/audit-log.js +4 -2
  155. package/src/bongos/routes/auth.js +8 -4
  156. package/src/bongos/routes/backup.js +7 -4
  157. package/src/bongos/routes/builders.js +23 -11
  158. package/src/bongos/routes/github.js +15 -4
  159. package/src/bongos/routes/llm-cache.js +5 -2
  160. package/src/bongos/routes/modules.js +9 -5
  161. package/src/bongos/routes/security.js +21 -12
  162. package/src/bongos/serve-internal.js +22 -7
  163. package/src/module-api.js +86 -13
  164. package/src/platform-server.js +14 -0
  165. package/tests/api_client.mjs +18 -2
  166. package/tests/api_path_404.mjs +3 -0
  167. package/tests/art_stage.mjs +2 -1
  168. package/tests/atlas.mjs +32 -4
  169. package/tests/auth_admission_gate.mjs +17 -5
  170. package/tests/auth_error_brand.mjs +41 -21
  171. package/tests/auth_resolve_failure.mjs +155 -0
  172. package/tests/blocker_link.mjs +88 -0
  173. package/tests/bongos_downloads.mjs +2 -1
  174. package/tests/box_dns_repoint.mjs +2 -0
  175. package/tests/box_ship_permissions.mjs +2 -0
  176. package/tests/box_terminal_ssrf.mjs +23 -17
  177. package/tests/claim_error_surface.mjs +23 -0
  178. package/tests/claude_materialize.mjs +4 -0
  179. package/tests/cli_box_scope.mjs +40 -0
  180. package/tests/cloud_bongos_pack.mjs +34 -4
  181. package/tests/conductor.mjs +2 -1
  182. package/tests/criterion_authoring_gate.mjs +28 -5
  183. package/tests/currency_label.mjs +10 -1
  184. package/tests/deploy_instance.mjs +2 -0
  185. package/tests/devbox_downloads.mjs +2 -1
  186. package/tests/display_name.mjs +95 -6
  187. package/tests/eviction_policy.mjs +2 -1
  188. package/tests/file_map.mjs +10 -0
  189. package/tests/first_admin_bootstrap.mjs +10 -2
  190. package/tests/first_run_engine_check.mjs +2 -1
  191. package/tests/fitness.mjs +26 -6
  192. package/tests/gate_approvals.mjs +15 -2
  193. package/tests/github_push_land_proof.mjs +50 -4
  194. package/tests/github_repos_helpers.mjs +30 -5
  195. package/tests/github_token_store.mjs +23 -0
  196. package/tests/goal_abuse_matrix.mjs +13 -3
  197. package/tests/goal_cli.mjs +89 -0
  198. package/tests/governance_abuse_matrix.mjs +386 -0
  199. package/tests/governance_fold.mjs +156 -0
  200. package/tests/governance_fold_bench.mjs +50 -0
  201. package/tests/governance_live_proof.mjs +201 -0
  202. package/tests/governance_page_gates.mjs +21 -9
  203. package/tests/governance_parity.mjs +11 -2
  204. package/tests/governance_require_permission.mjs +125 -1
  205. package/tests/governance_routes.mjs +334 -0
  206. package/tests/grader.mjs +7 -3
  207. package/tests/hall_nav.mjs +14 -4
  208. package/tests/helpers.mjs +73 -0
  209. package/tests/hook_core_resolve.mjs +2 -1
  210. package/tests/host_topology_skips.mjs +97 -0
  211. package/tests/infra_exec_paths.mjs +14 -4
  212. package/tests/init.mjs +128 -0
  213. package/tests/known_failing_ratchet.mjs +89 -0
  214. package/tests/lifecycle-card.mjs +2 -1
  215. package/tests/main_audit.mjs +72 -1
  216. package/tests/memory_isolation.mjs +56 -9
  217. package/tests/migrate_root_resolution.mjs +1 -1
  218. package/tests/mirror_redact.mjs +19 -0
  219. package/tests/module_api.mjs +3 -1
  220. package/tests/module_loader.mjs +4 -0
  221. package/tests/modules.mjs +35 -7
  222. package/tests/modules_route.mjs +29 -5
  223. package/tests/onboarding_observability.mjs +49 -11
  224. package/tests/onboarding_route_signals.mjs +1 -0
  225. package/tests/one_active_claim_per_session.mjs +80 -0
  226. package/tests/pre_compact.mjs +2 -1
  227. package/tests/preview_server.mjs +2 -1
  228. package/tests/profile_route.mjs +1 -0
  229. package/tests/provision.mjs +170 -0
  230. package/tests/provisioning_onboard_mode.mjs +180 -0
  231. package/tests/publish_manifest.mjs +16 -3
  232. package/tests/publish_reconciler.mjs +181 -10
  233. package/tests/rank_tier_single_source.mjs +205 -0
  234. package/tests/recall_hints.mjs +2 -1
  235. package/tests/repo_map.mjs +4 -0
  236. package/tests/route_permission_check.mjs +14 -5
  237. package/tests/rubric_fixtures.mjs +3 -0
  238. package/tests/run_routine_two_root.mjs +2 -0
  239. package/tests/sandbox_stage.mjs +69 -7
  240. package/tests/search_ingest.mjs +7 -0
  241. package/tests/security_docs.mjs +28 -6
  242. package/tests/sequence.mjs +64 -0
  243. package/tests/session-cards.mjs +2 -1
  244. package/tests/session_start_freshness.mjs +2 -1
  245. package/tests/ship_broadcast_server.mjs +63 -1
  246. package/tests/ship_card.mjs +66 -0
  247. package/tests/ship_ci_deploy.mjs +66 -1
  248. package/tests/ship_smoke_gate.mjs +76 -0
  249. package/tests/versions_route.mjs +15 -0
  250. package/tests/workflow_exec_paths.mjs +16 -4
  251. package/tests/worktree_guard.mjs +2 -1
@@ -0,0 +1,100 @@
1
+ // people.js — the People page: the /scouting cross-project builder directory
2
+ // graduated into the hall (task 1002577; ADR 0141 §4 is the data's design of
3
+ // record). Same data endpoint as scouting.js — /api/gds/scouting, Archon-gated
4
+ // server-side ('project.curate'); the page itself is gated in serve-internal.js
5
+ // on that exact permission, so a visitor without it never sees this shell. The
6
+ // hall chrome + kit primitives are what "graduate" means: searchable, paged,
7
+ // shell-navigable — spec R14 (goal 1000045) later widens WHO sees it, not this.
8
+ // All network values render through OTBKit.esc — never raw innerHTML.
9
+ (function () {
10
+ 'use strict';
11
+ var kit = window.OTBKit;
12
+ var esc = kit.esc;
13
+ var body = document.getElementById('people-body');
14
+
15
+ function fmtRecency(iso) {
16
+ if (!iso) return '—';
17
+ var t = Date.parse(iso);
18
+ if (isNaN(t)) return '—';
19
+ var days = Math.floor((Date.now() - t) / 86400000);
20
+ if (days <= 0) return 'today';
21
+ if (days === 1) return '1 day ago';
22
+ if (days < 30) return days + ' days ago';
23
+ return Math.floor(days / 30) + ' mo ago';
24
+ }
25
+
26
+ function builderName(b) {
27
+ return b.github_login || b.display_name || ('#' + b.github_id);
28
+ }
29
+
30
+ var all = [];
31
+ var query = '';
32
+
33
+ function matches(b) {
34
+ if (!query) return true;
35
+ var hay = (builderName(b) + ' ' + (b.display_name || '') + ' ' +
36
+ (Array.isArray(b.disciplines) ? b.disciplines.join(' ') : '')).toLowerCase();
37
+ return hay.indexOf(query) !== -1;
38
+ }
39
+
40
+ var pager = kit.makePager('people', 'hall-people-per-page');
41
+ var ledger = null;
42
+
43
+ function rowHtml(b) {
44
+ return '<tr>' +
45
+ '<td><span class="who">' + esc(builderName(b)) + '</span></td>' +
46
+ '<td class="num">' + esc(b.project_count != null ? b.project_count : '—') + '</td>' +
47
+ '<td class="num">' + esc(b.total_credits != null ? b.total_credits : 0) + '</td>' +
48
+ '<td class="num">' + esc(b.total_tasks_shipped != null ? b.total_tasks_shipped : 0) + '</td>' +
49
+ '<td class="num">' + esc(b.total_karma != null ? b.total_karma : 0) + '</td>' +
50
+ '<td>' + esc(Array.isArray(b.disciplines) ? b.disciplines.join(', ') : '—') + '</td>' +
51
+ '<td>' + esc(fmtRecency(b.last_active)) + '</td>' +
52
+ '</tr>';
53
+ }
54
+
55
+ function rerender() {
56
+ if (ledger) ledger.render(all.filter(matches));
57
+ }
58
+
59
+ function mountDirectory(builders) {
60
+ all = Array.isArray(builders) ? builders : [];
61
+ kit.makeFilterBar({
62
+ mount: document.getElementById('people-filters'),
63
+ search: { id: 'people-q', label: 'Search', placeholder: 'name or discipline' },
64
+ onChange: function (vals) {
65
+ query = String(vals['people-q'] || '').trim().toLowerCase();
66
+ pager.page = 1;
67
+ rerender();
68
+ },
69
+ });
70
+ body.textContent = '';
71
+ ledger = kit.makeLedger({
72
+ mount: body,
73
+ columns: ['Builder', { label: 'Projects', class: 'num' }, { label: 'Credits', class: 'num' },
74
+ { label: 'Ships', class: 'num' }, { label: 'Karma', class: 'num' }, 'Disciplines', 'Last active'],
75
+ rowHtml: rowHtml,
76
+ emptyMsg: 'No cross-project activity reported yet.',
77
+ pager: pager,
78
+ });
79
+ rerender();
80
+ }
81
+
82
+ function notice(msg) {
83
+ body.innerHTML = '';
84
+ body.className = 'note';
85
+ body.textContent = msg;
86
+ }
87
+
88
+ fetch('/api/gds/scouting?limit=500', { headers: { Accept: 'application/json' } })
89
+ .then(function (r) {
90
+ if (r.status === 403 || r.status === 401) { throw { gated: true }; }
91
+ if (!r.ok) throw new Error('http ' + r.status);
92
+ return r.json();
93
+ })
94
+ .then(function (data) { mountDirectory(data && data.builders); })
95
+ .catch(function (e) {
96
+ notice(e && e.gated
97
+ ? 'The builder directory is Archon-only for now.'
98
+ : 'Couldn’t load the directory right now.');
99
+ });
100
+ }());
@@ -82,6 +82,7 @@
82
82
  <script src="/builders/palette.js" defer></script>
83
83
  <!-- shared frontend toolkit (window.OTB) — loaded before profile.js, which pulls from it. -->
84
84
  <script src="/builders/dom-utils.js?v=2026-06-14-domutils"></script>
85
- <script src="/builders/profile.js?v=2026-06-14-domutils"></script>
85
+ <script src="/builders/brand-apply.js?v=2026-06-27-r75"></script>
86
+ <script src="/builders/profile.js?v=2026-08-04-reward1"></script>
86
87
  </body>
87
88
  </html>
@@ -131,6 +131,14 @@
131
131
  const tbody = $('#works-table tbody');
132
132
  const lede = $('#works-lede');
133
133
  if (!tbody) return;
134
+ // Every row here is a shipped task, so one header serves them all — but on a
135
+ // cost-plus-only instance the figure is still an unpaid estimate, so the
136
+ // label must keep its "Est." qualifier (ADR 0146).
137
+ const rewardTh = $('#works-table thead .ledger__drachmae');
138
+ if (rewardTh) {
139
+ rewardTh.textContent = window.OTBBranding?.taskCreditLabel?.({ status: 'shipped' }, { caps: true })
140
+ ?? (curLabel(true) + ' Est.');
141
+ }
134
142
  const list = Array.isArray(ships) ? ships : [];
135
143
  if (!list.length) {
136
144
  tbody.innerHTML = `<tr><td colspan="4" class="ledger__empty">Nothing shipped yet.</td></tr>`;
@@ -138,9 +146,7 @@
138
146
  return;
139
147
  }
140
148
  tbody.innerHTML = list.map((s) => {
141
- // credits_awarded is the actual ledger figure for a shipped task; fall back
142
- // to the creation-time estimate only if the awarded figure is absent.
143
- const reward = s.credits_awarded != null ? s.credits_awarded : s.credits_reward;
149
+ const reward = window.OTBBranding?.taskCreditValue?.(s) ?? s.credits_reward;
144
150
  const summary = s.value_summary
145
151
  ? `<span style="display:block;margin-top:2px;font-size:12px;color:var(--ink-faint)">${escapeHtml(s.value_summary)}</span>`
146
152
  : '';
@@ -208,8 +208,10 @@
208
208
  return;
209
209
  }
210
210
 
211
- if (!builder || builder.rank !== 'archon') {
212
- showSealed('The session log is available to Archons only. Your current rank does not have access.');
211
+ // ADR 0157: `session.search` moved archon→metic, so the session log is a
212
+ // Metic+ surface now. Cosmetic — the route gate is the wall.
213
+ if (!builder || (builder.rank !== 'metic' && builder.rank !== 'archon')) {
214
+ showSealed('The session log is available from the Metic rank up. Your current rank does not have access.');
213
215
  return;
214
216
  }
215
217
 
@@ -12,7 +12,7 @@
12
12
  // top bar (drawer toggle, page title, greeting slot) into the static mounts
13
13
  // (#app-sidebar / #app-topbar / #app-foot) every hall page carries;
14
14
  // - rank-gate nav items COSMETICALLY (server-side page gates remain the real
15
- // access control — serve-internal.js requireArchonPage etc.);
15
+ // access control — serve-internal.js requireGovernancePage etc.);
16
16
  // - on the index (body[data-page="home"]) route the three views (#home,
17
17
  // #standing, #leaderboard) plus the #achievements alias into Standing. The
18
18
  // `#/task|idea|blocker/N` deep-link hashes belong to builders.js and are
@@ -78,6 +78,7 @@
78
78
  goals: '<circle cx="9" cy="9" r="6.5"/><circle cx="9" cy="9" r="3.5"/><circle cx="9" cy="9" r="0.8" fill="currentColor"/>',
79
79
  roadmap: '<path d="M3 15.5V3.5l4 2 4-2 4 2v10l-4-2-4 2-4-2Z"/><path d="M7 3.5v10M11 5.5v10"/>',
80
80
  leaderboard: '<path d="M3.5 15.5v-6M9 15.5V3.5m5.5 12v-9"/>',
81
+ people: '<circle cx="6.5" cy="6.5" r="2.5"/><path d="M2.5 15c.5-2.4 2-3.7 4-3.7s3.5 1.3 4 3.7"/><circle cx="12.5" cy="7" r="2"/><path d="M12 11.5c1.8.1 3 1.3 3.5 3.3"/>',
81
82
  docs: '<path d="M4 3.5h7l3 3v10H4v-13Z"/><path d="M11 3.5v3h3"/>',
82
83
  settings: '<circle cx="9" cy="9" r="2.5"/><path d="M9 2.5v2m0 9v2M2.5 9h2m9 0h2M4.4 4.4l1.4 1.4m6.4 6.4 1.4 1.4m0-9.2-1.4 1.4M5.8 12.2l-1.4 1.4"/>',
83
84
  admin: '<path d="M9 2.5 15 5v4c0 3.5-2.4 6-6 7-3.6-1-6-3.5-6-7V5l6-2.5Z"/>',
@@ -96,10 +97,13 @@
96
97
  // work — signed, but for newcomers only once the "ship three" stage is
97
98
  // current (mirrors the old #hall-nav-work rule, task #833)
98
99
  // archon — rank === 'archon'
100
+ // governance — rank is metic or archon (ADR 0157): the monitoring/ops pages.
101
+ // Mirrors the server's `page.view.governance` floor, which that ADR
102
+ // lowered archon→metic. Cosmetic only — requireGovernancePage is the wall.
99
103
  // `view` marks an index view (hash-routed); `module` gates on window.__MODULES__.
100
104
  // task 2175: grouped by AUDIENCE of the data, so the sidebar reads in coherent
101
105
  // runs instead of one long list — the project (unlabeled), You, Community,
102
- // Docs, Archon. A group's `gate` must match its most permissive item's, or the
106
+ // Docs, Governance. A group's `gate` must match its most permissive item's, or the
103
107
  // head renders over nothing.
104
108
  var NAV = [
105
109
  { group: '', items: [
@@ -131,6 +135,12 @@
131
135
  ] },
132
136
  { group: 'Community', gate: 'member', items: [
133
137
  { id: 'leaderboard', label: 'Leaderboard', icon: 'leaderboard', href: '/#leaderboard', view: 'leaderboard', gate: 'member' },
138
+ // task 1002577: the cross-project builder directory (/scouting graduated
139
+ // into the hall). Archon-gated to MATCH its data — /api/gds/scouting is
140
+ // deliberately "NOT shown to builders" (ADR 0141 §4) until spec R14 widens
141
+ // it; this gate is cosmetic, requireProjectCuratePage is the wall. The
142
+ // module gate hides the tab on instances without the platform-identity hub.
143
+ { id: 'people', label: 'People', icon: 'people', href: '/people', gate: 'archon', module: 'platform-identity' },
134
144
  ] },
135
145
  { group: 'Docs', gate: 'signed', items: [
136
146
  { id: 'primer', label: 'Primer', icon: 'docs', href: '/primer', gate: 'member' },
@@ -150,11 +160,18 @@
150
160
  // subdomain — external so the rewrite shim doesn't turn it into /builders/docs.
151
161
  { id: 'apidocs', label: 'API docs', icon: 'docs', href: '/docs', gate: 'signed', external: true },
152
162
  ] },
153
- { group: 'Archon', gate: 'archon', items: [
154
- { id: 'watch', label: 'Watch', icon: 'admin', href: '/watch', gate: 'archon' },
155
- { id: 'harbor', label: 'Harbor', icon: 'admin', href: '/harbor', gate: 'archon', module: 'dev-box' },
156
- { id: 'gate', label: 'Gate', icon: 'admin', href: '/gate', gate: 'archon' },
157
- { id: 'sessions', label: 'Sessions', icon: 'admin', href: '/sessions', gate: 'archon' },
163
+ // Relabeled 'Archon' → 'Governance' in ADR 0157: these pages are no longer
164
+ // Archon-only, so a group header reading "Archon" over items a Metic can open
165
+ // would misdescribe the ladder to the builders most likely to be learning it.
166
+ { group: 'Governance', gate: 'governance', items: [
167
+ { id: 'watch', label: 'Watch', icon: 'admin', href: '/watch', gate: 'governance' },
168
+ { id: 'harbor', label: 'Harbor', icon: 'admin', href: '/harbor', gate: 'governance', module: 'dev-box' },
169
+ { id: 'gate', label: 'Gate', icon: 'admin', href: '/gate', gate: 'governance' },
170
+ { id: 'sessions', label: 'Sessions', icon: 'admin', href: '/sessions', gate: 'governance' },
171
+ // BV1.R113: the roles-and-permissions tab (ADR 0151). The one genuinely
172
+ // Archon-only item the 'governance' gate comment anticipated — cosmetic
173
+ // only; requireGovernanceManagePage (the governance.manage atom) is the wall.
174
+ { id: 'governance', label: 'Roles', icon: 'admin', href: '/governance', gate: 'archon' },
158
175
  ] },
159
176
  ];
160
177
 
@@ -365,6 +382,10 @@
365
382
  case 'member': return member;
366
383
  case 'work': return authed && (member || access.showWork);
367
384
  case 'archon': return authed && access.rank === 'archon';
385
+ // ADR 0157: the monitoring/ops pages sit at the `page.view.governance` floor,
386
+ // which is metic. Kept as its own key rather than widening 'archon', so a
387
+ // genuinely Archon-only nav item can still be added later.
388
+ case 'governance': return authed && (access.rank === 'metic' || access.rank === 'archon');
368
389
  default: return false;
369
390
  }
370
391
  }
@@ -199,9 +199,12 @@
199
199
  function triageError(err, fallback) {
200
200
  if (err && err.message === 'redirecting to auth') return null;
201
201
  var code = errCode(err);
202
- // 'rank_forbidden' is what auth.js requireRank actually emits; the 403 arm
203
- // also covers POST /tasks, which is Metic+ for the same reason.
204
- if (code === 'rank_forbidden' || (err && err.status === 403)) return 'Only Metic+ can triage ideas.';
202
+ // 'permission_forbidden' is what requirePermission emits (PATCH /inbox/:id →
203
+ // idea.triage, POST /tasks → task.create, both migrated in BV1.R105);
204
+ // 'rank_forbidden' is requireRank's older code, still emitted by the in-handler
205
+ // walls on these paths (e.g. PATCH /tasks/:id's security_sensitive check). Both
206
+ // mean the same thing to the reader, and the 403 arm backstops either.
207
+ if (code === 'permission_forbidden' || code === 'rank_forbidden' || (err && err.status === 403)) return 'Only Metic+ can triage ideas.';
205
208
  if (code === 'MERGE_SELF') return 'An idea can’t be merged into itself.';
206
209
  if (code === 'MERGE_TARGET_NOT_FOUND') return 'No idea with that number.';
207
210
  if (code === 'MERGE_TARGET_MERGED') return 'That idea was itself merged — merge into the one it folded into.';
@@ -1,14 +1,19 @@
1
1
  // watch.js — Watch (/builders/watch).
2
2
  //
3
- // Archon-only (rank 'archon') monitoring view. One self-contained page that
4
- // stitches together the oversight surfaces: pending access requests, security
5
- // (red-team reports + override queue + audit log), the builder roster,
6
- // per-builder + project-wide review grades, estimation drift, repo health,
7
- // and a tally of recorded sessions.
3
+ // Metic+ monitoring view (Archon-only until ADR 0157 delegated the operational
4
+ // permissions to the working rank). One self-contained page that stitches together
5
+ // the oversight surfaces: pending access requests, security (red-team reports +
6
+ // override queue + audit log), the builder roster, per-builder + project-wide
7
+ // review grades, estimation drift, repo health, and a tally of recorded sessions.
8
+ //
9
+ // Note the page floor and the panel floors are no longer identical: the page and
10
+ // most panels sit at metic, but ACCESS REQUESTS (`access_request.review`) stayed
11
+ // archon — admitting a builder is identity. So a Metic sees this page with that one
12
+ // section sealed, which is the 403-degrades-per-section path below working as designed.
8
13
  //
9
14
  // This page reads /api/bongos/me to learn the viewer's rank and shows a
10
- // restricted notice for non-Archons — but that is DEFENCE IN DEPTH only. Every
11
- // endpoint below is ALSO gated to rank 'archon' server-side (ADR 0016: the DB
15
+ // restricted notice below the Metic floor — but that is DEFENCE IN DEPTH only. Every
16
+ // endpoint below is ALSO gated server-side (ADR 0016: the DB
12
17
  // enforces, the markdown/UI only describes). On a 401 it redirects into the
13
18
  // web sign-in flow; on a 403 the affected section degrades to an inline
14
19
  // "unavailable" message rather than blanking the whole page. Each section
@@ -870,8 +875,11 @@
870
875
  return;
871
876
  }
872
877
 
873
- if (!builder || builder.rank !== 'archon') {
874
- showSealed('This page is available to Archons only. Your current rank does not grant access.');
878
+ // ADR 0157: Watch sits at the `page.view.governance` floor (metic), and every panel
879
+ // it reads (roster, security reports, audit log, sessions, box costs) delegated to
880
+ // metic with it. Cosmetic — requireGovernancePage + the route gates are the wall.
881
+ if (!builder || (builder.rank !== 'metic' && builder.rank !== 'archon')) {
882
+ showSealed('This page is available from the Metic rank up. Your current rank does not grant access.');
875
883
  return;
876
884
  }
877
885
 
@@ -81,14 +81,49 @@ async function resolveBlocker({ id, builderId, resolutionNote }) {
81
81
  return { blocker: rows[0], promoted_tasks: promoted };
82
82
  }
83
83
 
84
- // Link a blocker to a task (lives in task_blockers via the new blocker_id col).
84
+ // Statuses that linking a blocker moves to 'blocked' (task 1002559).
85
+ //
86
+ // 'ready' only, deliberately. The resolve trigger (migration 009) promotes
87
+ // 'blocked' → 'ready', so anything we flip INTO blocked must be something
88
+ // 'ready' is a correct destination for. 'backlog' is not: it is pre-workable
89
+ // and already unclaimable, and migration 176 keeps a dep-ship from promoting it
90
+ // past an open blocker — so blocking it would buy nothing and would let a
91
+ // blocker resolution launder a task past the metic+archon promote gate.
92
+ const LINK_BLOCKABLE_STATUSES = ['ready'];
93
+
94
+ function blocksOnLink(status) {
95
+ return LINK_BLOCKABLE_STATUSES.includes(status);
96
+ }
97
+
98
+ // Link a blocker to a task (lives in task_blockers via the new blocker_id col)
99
+ // and park the task in 'blocked' so the resolve-side trigger has something to
100
+ // promote. Both writes share one transaction: a link that recorded the edge but
101
+ // left the task claimable is the exact defect this replaces.
85
102
  async function linkBlockerToTask({ blockerId, taskId }) {
86
- await pool.query(
87
- `INSERT INTO task_blockers (task_id, blocker_id, blocker_ref)
88
- VALUES ($1, $2, $3)
89
- ON CONFLICT DO NOTHING`,
90
- [taskId, blockerId, `blocker:${blockerId}`]
91
- );
103
+ const client = await pool.connect();
104
+ try {
105
+ await client.query('BEGIN');
106
+ await client.query(
107
+ `INSERT INTO task_blockers (task_id, blocker_id, blocker_ref)
108
+ VALUES ($1, $2, $3)
109
+ ON CONFLICT DO NOTHING`,
110
+ [taskId, blockerId, `blocker:${blockerId}`]
111
+ );
112
+ const { rows } = await client.query(
113
+ `UPDATE tasks
114
+ SET status = 'blocked', updated_at = now()
115
+ WHERE id = $1 AND status = ANY($2)
116
+ RETURNING id`,
117
+ [taskId, LINK_BLOCKABLE_STATUSES]
118
+ );
119
+ await client.query('COMMIT');
120
+ return { blocked: rows.length > 0 };
121
+ } catch (err) {
122
+ await client.query('ROLLBACK');
123
+ throw err;
124
+ } finally {
125
+ client.release();
126
+ }
92
127
  }
93
128
 
94
129
  module.exports = {
@@ -97,4 +132,6 @@ module.exports = {
97
132
  createBlocker,
98
133
  resolveBlocker,
99
134
  linkBlockerToTask,
135
+ blocksOnLink,
136
+ LINK_BLOCKABLE_STATUSES,
100
137
  };
@@ -73,7 +73,7 @@ module.exports = function buildBlockersRouter() {
73
73
  // /blockers above); a Xenos cannot resolve or link them.
74
74
  // rank: metic+archon — blocker review (auto-promotes linked blocked tasks);
75
75
  // criterion C2 / #360 / ADR 0018.
76
- router.post('/blockers/:id/resolve', api.requireBuilder, api.requireRank('metic', 'archon'), async (req, res) => {
76
+ router.post('/blockers/:id/resolve', api.requireBuilder, api.requirePermission('blocker.review'), async (req, res) => {
77
77
  if (validateOrRespond(req, res, {
78
78
  resolution_note: { type: 'string', maxLength: LIMITS.NOTES },
79
79
  })) return;
@@ -95,11 +95,13 @@ module.exports = function buildBlockersRouter() {
95
95
  }
96
96
  });
97
97
 
98
- // Link an existing blocker to a task (puts the task in 'blocked' status if
99
- // it's currently 'backlog' or 'ready'). Metic+ (#360 / ADR 0018) — same
100
- // blocker-review grant as resolve above.
98
+ // Link an existing blocker to a task. A task that is currently 'ready' is
99
+ // parked in 'blocked' so it stops advertising as claimable and the
100
+ // resolve-side trigger can release it; every other status is left alone
101
+ // (task 1002559 — see LINK_BLOCKABLE_STATUSES for why 'backlog' is excluded).
102
+ // Metic+ (#360 / ADR 0018) — same blocker-review grant as resolve above.
101
103
  // rank: metic+archon — link a blocker to a task; same blocker-review grant as resolve.
102
- router.post('/blockers/:id/link', api.requireBuilder, api.requireRank('metic', 'archon'), async (req, res) => {
104
+ router.post('/blockers/:id/link', api.requireBuilder, api.requirePermission('blocker.review'), async (req, res) => {
103
105
  if (validateOrRespond(req, res, {
104
106
  task_id: { required: true }, // accept number or stringified number; coerced below
105
107
  })) return;
@@ -108,8 +110,11 @@ module.exports = function buildBlockersRouter() {
108
110
  const taskId = Number(req.body?.task_id);
109
111
  if (!Number.isFinite(taskId)) return res.fail('task_id_required', 400);
110
112
  try {
111
- await blockers.linkBlockerToTask({ blockerId: id, taskId });
112
- res.json({ ok: true });
113
+ // `blocked` says whether the task actually moved. The old handler always
114
+ // answered {ok:true} even when nothing changed, which is how a dead link
115
+ // loop went unnoticed for six weeks (task 1002559).
116
+ const { blocked } = await blockers.linkBlockerToTask({ blockerId: id, taskId });
117
+ res.json({ ok: true, blocked });
113
118
  } catch (err) {
114
119
  console.error('[gds] POST /blockers/:id/link', err);
115
120
  res.fail('link_failed', { status: 500, message: 'internal error' });
@@ -129,7 +129,7 @@ module.exports = function buildInboxRouter() {
129
129
  // archon-only to metic+archon when the three-rank model went live. Any
130
130
  // builder can still FILE an idea (POST /inbox above); a Xenos cannot triage.
131
131
  // rank: metic+archon — idea triage shapes the backlog; criterion C2 / #360 / ADR 0018.
132
- router.patch('/inbox/:id', api.requireBuilder, api.requireRank('metic', 'archon'), async (req, res) => {
132
+ router.patch('/inbox/:id', api.requireBuilder, api.requirePermission('idea.triage'), async (req, res) => {
133
133
  if (validateOrRespond(req, res, {
134
134
  // Enum kept light — inbox.updateIdea returns a structured BAD_STATUS
135
135
  // error if the value falls outside the accepted set, and the supervisor
@@ -211,7 +211,7 @@ module.exports = function buildInboxRouter() {
211
211
  router.post(
212
212
  '/inbox/:id/ratify',
213
213
  api.requireBuilder,
214
- api.requireRank('archon'),
214
+ api.requirePermission('idea.ratify'),
215
215
  async (req, res) => {
216
216
  // notes_md is OPTIONAL and free text. The schema keeps the accepted shape
217
217
  // exactly as it was (absent → ''), and adds what the typeof guard could not:
@@ -29,7 +29,7 @@
29
29
  'use strict';
30
30
 
31
31
  const api = require('../../src/module-api');
32
- const { pool, withTx, insertAuditLog, LIVE_RANK_LADDER } = api;
32
+ const { pool, withTx, insertAuditLog, LIVE_RANK_LADDER, RANK_TIER } = api;
33
33
  // The seam registry (resolveOptional/emit/emitAsync) — the doorway re-exports it,
34
34
  // so the module reaches the `reward`/`onboarding` ports + emits kernel events
35
35
  // (task.shipped, builder.box-reconcile-needed) without importing core.
@@ -149,10 +149,10 @@ function qualifiesForThetesGraduation(rank, shippedCount) {
149
149
 
150
150
  // GENERAL_QUEUE_RANKS — the live ranks allowed to claim any ready task (the
151
151
  // whole general queue). Everything NOT in this set is sandboxed (see
152
- // xenosClaimAllowed). Mirrors LIVE_RANK_LADDER minus the sandboxed entry rank
153
- // 'xenos': thetes (graduated newcomer, #692 / ADR 0034) and up.
152
+ // xenosClaimAllowed). LIVE_RANK_LADDER minus the sandboxed entry rank 'xenos':
153
+ // thetes (graduated newcomer, #692 / ADR 0034) and up — derived (BV1.R108).
154
154
 
155
- const GENERAL_QUEUE_RANKS = new Set(['thetes', 'metic', 'archon']);
155
+ const GENERAL_QUEUE_RANKS = new Set(LIVE_RANK_LADDER.filter((r) => r !== 'xenos'));
156
156
 
157
157
  // xenosClaimAllowed — the row-level half of the three-rank model (#360 / ADR
158
158
  // 0018; renamed in V3.R15 #239 to newcomer_friendly). The sandboxed entry rank
@@ -231,7 +231,10 @@ async function claimPermissionsFor(builderId) {
231
231
  // claimTask + claimTasksBatch both call it after xenosClaimAllowed; the two
232
232
  // gates compose without contradiction (see migration 044 for the rationale).
233
233
 
234
- const REQUIRES_RANK_TIER = { xenos: 1, thetes: 2, metic: 3, archon: 4 };
234
+ // BV1.R108: no longer a hand-copied literal — the canonical map is DERIVED from
235
+ // LIVE_RANK_LADDER in db-kernel and reaches this module through the doorway.
236
+ // The local name survives because ~10 helpers below and the module export read it.
237
+ const REQUIRES_RANK_TIER = RANK_TIER;
235
238
 
236
239
  function rankAllowsTask(builderRank, requiredRank) {
237
240
  // Tasks default to requires_rank='xenos' (the NOT NULL DEFAULT on the
@@ -575,13 +578,16 @@ async function versionProgress() {
575
578
  // commit together, so a bad criterion leaves nothing half-created. Criteria are
576
579
  // idempotent on (version_id, criterion_id) so a re-run can't double-insert.
577
580
 
578
- async function createVersion({ id, name, status = 'planning', scopeDocPath = null, doneWhen = null, criteria = [] }) {
581
+ async function createVersion({ id, name, status = 'planning', track = 'product', scopeDocPath = null, doneWhen = null, criteria = [] }) {
582
+ // versions.track is NOT NULL with no DB default (migration 003) — omitting it
583
+ // here made every create 500. An instance's first version is its product
584
+ // track, so that's the default; 'internal' is the platform's own track.
579
585
  return withTx(async (client) => {
580
586
  const { rows } = await client.query(
581
- `INSERT INTO versions (id, name, status, scope_doc_path, done_when)
582
- VALUES ($1, $2, $3, $4, $5)
583
- RETURNING id, name, status, scope_doc_path, started_at, shipped_at, done_when`,
584
- [id, name, status, scopeDocPath, doneWhen]
587
+ `INSERT INTO versions (id, name, status, track, scope_doc_path, done_when)
588
+ VALUES ($1, $2, $3, $4, $5, $6)
589
+ RETURNING id, name, status, track, scope_doc_path, started_at, shipped_at, done_when`,
590
+ [id, name, status, track, scopeDocPath, doneWhen]
585
591
  );
586
592
  const version = rows[0];
587
593
  const createdCriteria = [];
@@ -2479,6 +2485,35 @@ async function unsatisfiedDeps(taskId, client = pool) {
2479
2485
  // overlapping touches[] (caught BEFORE the unique-violation, so we
2480
2486
  // give a useful error rather than a generic constraint failure).
2481
2487
 
2488
+ // task 1002589 (idea 1000638) — a claims INSERT can trip TWO partial unique
2489
+ // indexes, and each means something different to the caller:
2490
+ // uniq_active_claim_per_task — race lost: another session claimed the task.
2491
+ // the per-session backstop — this session already holds a bound active claim
2492
+ // in the same working tree. Collapsing this case onto ALREADY_CLAIMED sent
2493
+ // builders hunting a phantom rival claimant while the target task sat
2494
+ // 'ready'.
2495
+ // Both generations of the backstop index map to the honest code: a deploy
2496
+ // applies code and migration at different instants, and an instance may still
2497
+ // carry the old index.
2498
+ const SESSION_CLAIM_INDEXES = new Set([
2499
+ 'uniq_active_claim_per_session', // migration 180 — session-only (pre-re-key)
2500
+ 'uniq_active_claim_per_session_worktree', // core_204 (task 1002570) — per (session, worktree)
2501
+ ]);
2502
+ // Branch on err.constraint; an UNKNOWN constraint fails CLOSED on the generic
2503
+ // ALREADY_CLAIMED (never widen a claim refusal). Pure + exported for DB-free
2504
+ // tests; claimTask enriches the session case with the in-flight claim's id.
2505
+ function claimUniqueViolationError(err) {
2506
+ if (err && SESSION_CLAIM_INDEXES.has(err.constraint)) {
2507
+ return {
2508
+ code: 'session_already_has_bound_claim',
2509
+ what_to_do: 'Ship or release the in-flight claim first (/builder-ship or /builder-release), '
2510
+ + 'or claim this task from its own separate worktree (since core_204 the index allows '
2511
+ + 'one bound claim per worktree). {"bind_session":false} (an unbound claim) always coexists.',
2512
+ };
2513
+ }
2514
+ return { code: 'ALREADY_CLAIMED' };
2515
+ }
2516
+
2482
2517
  async function claimTask({ taskId, builderId, worktreeName, headSha, creatorSessionId, enforceOverlap = false }) {
2483
2518
  const heldPermissions = await claimPermissionsFor(builderId); // BV1.R106 — before the tx opens
2484
2519
  const client = await pool.connect();
@@ -2716,8 +2751,31 @@ async function claimTask({ taskId, builderId, worktreeName, headSha, creatorSess
2716
2751
  } catch (err) {
2717
2752
  await client.query('ROLLBACK').catch(() => {});
2718
2753
  if (err && err.code === '23505') {
2719
- // unique_violation on uniq_active_claim_per_task — race lost
2720
- throw { code: 'ALREADY_CLAIMED' };
2754
+ // task 1002589: branch on WHICH unique index tripped — see
2755
+ // claimUniqueViolationError above. The session case is enriched with the
2756
+ // in-flight bound claim (post-ROLLBACK, same client) so the refusal
2757
+ // points at the real cause; best-effort — the honest code must not
2758
+ // depend on this read succeeding.
2759
+ let mapped = claimUniqueViolationError(err);
2760
+ if (mapped.code === 'session_already_has_bound_claim' && creatorSessionId) {
2761
+ try {
2762
+ // Same-worktree row first: under the core_204 index that is the
2763
+ // exact colliding claim; under the old session-only index any bound
2764
+ // row of the session is the culprit, so fall back to the earliest.
2765
+ const { rows } = await client.query(
2766
+ `SELECT c.task_id, t.title
2767
+ FROM claims c JOIN tasks t ON t.id = c.task_id
2768
+ WHERE c.creator_session_id = $1 AND c.released_at IS NULL
2769
+ ORDER BY (COALESCE(c.worktree_name, '') = COALESCE($2, '')) DESC, c.claimed_at, c.id
2770
+ LIMIT 1`,
2771
+ [creatorSessionId, worktreeName ?? null]
2772
+ );
2773
+ if (rows[0]) {
2774
+ mapped = { ...mapped, active_task_id: Number(rows[0].task_id), active_task_title: rows[0].title };
2775
+ }
2776
+ } catch (_) { /* enrichment only — the mapped code still throws */ }
2777
+ }
2778
+ throw mapped;
2721
2779
  }
2722
2780
  throw err;
2723
2781
  } finally {
@@ -2941,11 +2999,14 @@ async function claimTasksBatch({ taskIds, builderId, worktreePrefix, creatorSess
2941
2999
  // UPDATE and INSERT (it can't, given the row lock, but defense in depth),
2942
3000
  // we surface that as ALREADY_CLAIMED for the specific task.
2943
3001
  //
2944
- // task 1802 — uniq_active_claim_per_session (migration 180) is keyed on
2945
- // creator_session_id alone, so it can't tell "N rows from one batch call"
3002
+ // task 1802 — uniq_active_claim_per_session (migration 180) was keyed on
3003
+ // creator_session_id alone, so it couldn't tell "N rows from one batch call"
2946
3004
  // from "N rows from N separate /claims calls": every row here used to carry
2947
3005
  // the SAME non-null creatorSessionId for a real (bound) CLI session, so the
2948
- // 2nd INSERT always hit the partial unique index. Each batch-claimed task
3006
+ // 2nd INSERT always hit the partial unique index. (core_204 / task
3007
+ // 1002570 re-keyed it per (session, worktree) — but a prefix-less batch
3008
+ // stores NULL worktree_name on every row, which the index folds into ONE
3009
+ // group, so first-row-only binding stays load-bearing.) Each batch-claimed task
2949
3010
  // also gets its OWN worktree name (worktreePrefix-taskId, right below) — a
2950
3011
  // batch is claiming N tasks for N future (possibly parallel) working
2951
3012
  // sessions, not one shared worktree — so there is no correctness reason to
@@ -2982,7 +3043,11 @@ async function claimTasksBatch({ taskIds, builderId, worktreePrefix, creatorSess
2982
3043
  inserted.push(insertClaim.rows[0]);
2983
3044
  } catch (err) {
2984
3045
  if (err && err.code === '23505') {
2985
- throw { code: 'BATCH_FAILED', failures: [{ task_id: t.id, reason: 'ALREADY_CLAIMED' }] };
3046
+ // task 1002589: same constraint branch as claimTask — the per-session
3047
+ // index can trip here on the FIRST (bound) row when the calling
3048
+ // session already holds a bound claim; every other 23505 stays the
3049
+ // per-task race answer.
3050
+ throw { code: 'BATCH_FAILED', failures: [{ task_id: t.id, reason: claimUniqueViolationError(err).code }] };
2986
3051
  }
2987
3052
  throw err;
2988
3053
  }
@@ -3663,7 +3728,8 @@ async function listConfirmedTasksForReconcile({ limit = 100 } = {}) {
3663
3728
  // confirm time. The reconciler ages out silent strands on it (task 1546 / blocker 47:
3664
3729
  // 15 tasks sat unprovable for up to 66h because every sweep miss was a silent continue).
3665
3730
  const { rows } = await pool.query(
3666
- `SELECT DISTINCT ON (t.id) t.id AS task_id, c.builder_id, c.worktree_name, c.head_sha,
3731
+ `SELECT DISTINCT ON (t.id) t.id AS task_id, t.title, t.security_sensitive,
3732
+ c.builder_id, c.worktree_name, c.head_sha,
3667
3733
  c.published_head_sha, t.updated_at AS confirmed_at
3668
3734
  FROM tasks t
3669
3735
  JOIN claims c ON c.task_id = t.id
@@ -5344,6 +5410,7 @@ module.exports = {
5344
5410
  removeTaskCriterion,
5345
5411
  linkTaskCriteria,
5346
5412
  claimTask,
5413
+ claimUniqueViolationError,
5347
5414
  claimTasksBatch,
5348
5415
  releaseClaimsBatch,
5349
5416
  validateClaimBatch,