@bongos/core 1.24.14 → 1.24.15

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 (161) hide show
  1. package/.bongos-core.json +296 -136
  2. package/.claude/skills/blocker-review/SKILL.md +2 -0
  3. package/.claude/skills/session-board/SKILL.md +29 -0
  4. package/.claude/skills/strand-fix/SKILL.md +1 -1
  5. package/clients/bongos-client/README.md +1 -1
  6. package/clients/bongos-client/bongos-client.global.js +14 -0
  7. package/clients/bongos-client/index.cjs +14 -0
  8. package/clients/bongos-client/index.d.ts +23 -2
  9. package/clients/bongos-client/index.mjs +14 -0
  10. package/docs/adr/0385-money-lives-with-a-processor-credits-stay-a-project-meter.md +4 -0
  11. package/docs/api/openapi.json +369 -6
  12. package/docs/api-reference.md +19 -4
  13. package/docs/architecture.md +6 -0
  14. package/docs/copy-inventory.md +138 -123
  15. package/docs/copy-registry.json +296 -171
  16. package/docs/file-map.md +1 -0
  17. package/docs/module-api-changelog.md +2 -2
  18. package/docs/page-inventory.json +6 -1
  19. package/docs/page-readings.json +1040 -1021
  20. package/migrations/core_289_task_short_name.sql +34 -0
  21. package/modules/ai-studio/db.js +10 -0
  22. package/modules/ai-studio/gemini.js +73 -5
  23. package/modules/ai-studio/openrouter.js +17 -2
  24. package/modules/ai-studio/routes/ai-studio.js +7 -1
  25. package/modules/ai-studio/studio.js +8 -0
  26. package/modules/builder-settings/checkout-freshness.js +71 -0
  27. package/modules/builder-settings/routes/settings.js +26 -0
  28. package/modules/builder-settings/settings.js +2 -0
  29. package/modules/economy/migrations/economy_002_project_account.sql +83 -0
  30. package/modules/economy/module.json +1 -1
  31. package/modules/economy/project-account.js +166 -0
  32. package/modules/economy/routes/project-account.js +40 -0
  33. package/modules/hall-ui/public/ai-studio.css +3 -0
  34. package/modules/hall-ui/public/ai-studio.html +2 -1
  35. package/modules/hall-ui/public/ai-studio.js +72 -1
  36. package/modules/hall-ui/public/atlas.html +1 -1
  37. package/modules/hall-ui/public/blockers.html +1 -1
  38. package/modules/hall-ui/public/board-room.html +1 -1
  39. package/modules/hall-ui/public/city.html +1 -1
  40. package/modules/hall-ui/public/collab.html +1 -1
  41. package/modules/hall-ui/public/commands.html +1 -1
  42. package/modules/hall-ui/public/copy-desk.html +1 -1
  43. package/modules/hall-ui/public/deploy.html +1 -1
  44. package/modules/hall-ui/public/diagrams.html +1 -1
  45. package/modules/hall-ui/public/drachmae.html +1 -1
  46. package/modules/hall-ui/public/fleet.html +1 -1
  47. package/modules/hall-ui/public/gate.html +1 -1
  48. package/modules/hall-ui/public/github-chip.js +85 -0
  49. package/modules/hall-ui/public/goal-map.html +1 -1
  50. package/modules/hall-ui/public/goals.html +1 -1
  51. package/modules/hall-ui/public/government.html +1 -1
  52. package/modules/hall-ui/public/idea.html +1 -1
  53. package/modules/hall-ui/public/ideas.html +1 -1
  54. package/modules/hall-ui/public/index.html +1 -1
  55. package/modules/hall-ui/public/market-watch-item.html +2 -2
  56. package/modules/hall-ui/public/market-watch-item.js +10 -3
  57. package/modules/hall-ui/public/market-watch-item.states.json +41 -0
  58. package/modules/hall-ui/public/market-watch-lib.js +36 -5
  59. package/modules/hall-ui/public/market-watch-rules.html +1 -1
  60. package/modules/hall-ui/public/market-watch-settings.html +1 -1
  61. package/modules/hall-ui/public/market-watch.css +5 -0
  62. package/modules/hall-ui/public/market-watch.html +2 -2
  63. package/modules/hall-ui/public/modules.html +1 -1
  64. package/modules/hall-ui/public/my-agents.html +1 -1
  65. package/modules/hall-ui/public/my-agents.js +3 -0
  66. package/modules/hall-ui/public/primer.html +1 -1
  67. package/modules/hall-ui/public/profile.html +1 -1
  68. package/modules/hall-ui/public/project-settings.html +1 -1
  69. package/modules/hall-ui/public/ranks.html +1 -1
  70. package/modules/hall-ui/public/roadmap.html +1 -1
  71. package/modules/hall-ui/public/roster.html +1 -1
  72. package/modules/hall-ui/public/sessions.html +1 -1
  73. package/modules/hall-ui/public/settings.html +1 -1
  74. package/modules/hall-ui/public/shell.js +22 -22
  75. package/modules/hall-ui/public/street.html +1 -1
  76. package/modules/hall-ui/public/studio.html +1 -1
  77. package/modules/hall-ui/public/style.css +28 -0
  78. package/modules/hall-ui/public/task.html +1 -1
  79. package/modules/hall-ui/public/thinking.html +1 -1
  80. package/modules/hall-ui/public/tweak-editor.html +1 -1
  81. package/modules/hall-ui/public/watch.html +1 -1
  82. package/modules/hall-ui/public/work.html +1 -1
  83. package/modules/ideas/blockers.js +13 -7
  84. package/modules/lifecycle/db-claim-reads.js +1 -1
  85. package/modules/lifecycle/db-ship.js +2 -2
  86. package/modules/lifecycle/db-tasks.js +4 -4
  87. package/modules/lifecycle/migrations/lifecycle_019_pay_type_gates.sql +50 -0
  88. package/modules/lifecycle/module.json +1 -0
  89. package/modules/lifecycle/pay-type-gates.js +180 -0
  90. package/modules/lifecycle/publish-reconciler.js +31 -1
  91. package/modules/lifecycle/rebase-gate.js +3 -2
  92. package/modules/lifecycle/routes/claims.js +3 -2
  93. package/modules/lifecycle/routes/pay-type-gates.js +59 -0
  94. package/modules/lifecycle/routes/project-settings.js +4 -0
  95. package/modules/lifecycle/routes/task-write-routes.js +43 -0
  96. package/modules/lifecycle/ship-card.js +4 -1
  97. package/modules/lifecycle/task-name-db.js +55 -0
  98. package/modules/lifecycle/task-name.js +140 -0
  99. package/modules/market-watch/db.js +10 -1
  100. package/modules/market-watch/migrations/market_watch_004_entry_corrections.sql +20 -0
  101. package/modules/market-watch/pages.js +20 -1
  102. package/modules/market-watch/proposals.js +57 -7
  103. package/modules/market-watch/routes/pages.js +2 -0
  104. package/modules/market-watch/routes/rules.js +4 -0
  105. package/modules/platform-identity/terms-acceptance.js +9 -9
  106. package/modules/public-landing/public/account-google.states.json +14 -0
  107. package/modules/public-landing/public/assets/cosmos.css +12 -0
  108. package/modules/public-landing/public/assets/hub-bar.js +44 -1
  109. package/modules/public-landing/public/privacy.html +5 -1
  110. package/modules/public-landing/public/projects-google.states.json +14 -0
  111. package/modules/public-landing/public/projects.html +39 -0
  112. package/modules/public-landing/public/terms.html +12 -2
  113. package/modules/ui-design/kit/fixtures/me-google.json +10 -0
  114. package/modules/ui-design/kit/fixtures/me-sign-in-methods-google.json +4 -0
  115. package/modules/ui-design/kit/serve.js +7 -1
  116. package/package-lock.json +2 -2
  117. package/package.json +1 -1
  118. package/release-notes.json +49 -1
  119. package/scripts/bongos/claim.js +4 -2
  120. package/scripts/bongos/land-watch.js +1 -0
  121. package/scripts/bongos/report-checkout.js +30 -0
  122. package/scripts/bongos/run-unit-tests.js +11 -0
  123. package/scripts/bongos/sessions-board.js +264 -0
  124. package/scripts/bongos/ship-finish.js +1 -0
  125. package/scripts/bongos/ship-flow.js +5 -2
  126. package/scripts/bongos/strand-lease.js +5 -3
  127. package/scripts/bongos/strand-watch.js +38 -7
  128. package/scripts/gds/report-checkout.js +7 -0
  129. package/scripts/gds/sessions-board.js +7 -0
  130. package/src/bongos/agent-standing-rules.js +13 -3
  131. package/src/bongos/routes/me.js +7 -1
  132. package/src/module-api.js +1 -1
  133. package/tests/agent_standing_rules.mjs +18 -1
  134. package/tests/ai_studio_billing.mjs +13 -0
  135. package/tests/ai_studio_gemini_models.mjs +81 -0
  136. package/tests/ai_studio_requests.mjs +24 -0
  137. package/tests/blocker_detail_hydration.mjs +5 -3
  138. package/tests/blocker_tasks_named_db.mjs +74 -0
  139. package/tests/claim_gate_ci_unblock.mjs +1 -1
  140. package/tests/github_chip.mjs +217 -0
  141. package/tests/hall_ai_studio.mjs +129 -5
  142. package/tests/host_topology_skips.mjs +1 -0
  143. package/tests/hub_account_page.mjs +3 -2
  144. package/tests/landing_legal_pages.mjs +3 -2
  145. package/tests/market_watch_data_db.mjs +6 -0
  146. package/tests/market_watch_pages.mjs +87 -2
  147. package/tests/market_watch_rules.mjs +101 -5
  148. package/tests/market_watch_rules_db.mjs +42 -0
  149. package/tests/nav_permission_atoms.mjs +1 -1
  150. package/tests/pay_terms.mjs +8 -0
  151. package/tests/pay_type_gates.mjs +234 -0
  152. package/tests/pay_type_gates_db.mjs +84 -0
  153. package/tests/project_account.mjs +125 -0
  154. package/tests/project_account_db.mjs +97 -0
  155. package/tests/self_healing_start.mjs +162 -0
  156. package/tests/sessions_board.mjs +153 -0
  157. package/tests/stop_refcheck.mjs +40 -0
  158. package/tests/task_name.mjs +237 -0
  159. package/tests/task_name_db.mjs +79 -0
  160. package/tests/terms_acceptance.mjs +25 -3
  161. package/tests/terms_grandfather_db.mjs +11 -4
@@ -21,7 +21,7 @@ async function getActiveClaimsForBuilder(builderId) {
21
21
  `SELECT c.id AS claim_id, c.task_id, c.worktree_name, c.claimed_at,
22
22
  c.head_sha, c.reserved_migration_number,
23
23
  c.release_requested_by, c.release_requested_at, c.release_requested_note,
24
- t.title, t.touches, t.priority, t.credits_reward, t.version_id, t.module_key,
24
+ t.title, t.short_name, t.touches, t.priority, t.credits_reward, t.version_id, t.module_key,
25
25
  -- task 1003855: the craft of the claimed work. A speciality contract is
26
26
  -- craft-scoped, so the Conductor needs to know which craft is in play
27
27
  -- before it can pick the right one to inject.
@@ -485,7 +485,7 @@ const UNPROVABLE_STRAND_REASON = 'strand:no_branch_no_tip';
485
485
  // release it.
486
486
  async function gatingRebaseTasksForBuilder(builderId, client = pool, graceMs = UNPROVABLE_GATE_GRACE_MS) {
487
487
  const { rows } = await client.query(
488
- `SELECT id, title, needs_rebase_reason, needs_rebase_at
488
+ `SELECT id, title, short_name, needs_rebase_reason, needs_rebase_at
489
489
  FROM tasks
490
490
  WHERE needs_rebase = true AND status = 'confirmed' AND shipped_by = $1
491
491
  AND NOT (
@@ -501,7 +501,7 @@ async function gatingRebaseTasksForBuilder(builderId, client = pool, graceMs = U
501
501
 
502
502
  async function needsRebaseTasksForBuilder(builderId, client = pool) {
503
503
  const { rows } = await client.query(
504
- `SELECT id, title, needs_rebase_reason, needs_rebase_at
504
+ `SELECT id, title, short_name, needs_rebase_reason, needs_rebase_at
505
505
  FROM tasks
506
506
  WHERE needs_rebase = true AND status = 'confirmed' AND shipped_by = $1
507
507
  ORDER BY needs_rebase_at ASC NULLS LAST, id ASC`,
@@ -24,6 +24,7 @@ const { gateOnSpecTask } = require('./goal-spec-task.js');
24
24
  const { ensureArtistReviewGoal } = require('./artist-review-goal.js');
25
25
  const { notPageMarkerSql } = require('./page-tweak-artist.js');
26
26
  const { needsFilterWhere, capabilityFeedWhere } = require('./task-needs-gate.js');
27
+ const { deriveTaskName } = require('./task-name.js');
27
28
 
28
29
  // -------------------------------------------------------------------------
29
30
  // Tasks
@@ -270,13 +271,13 @@ async function createTask({
270
271
  est_minutes, est_cost_usd, manual_degree, priority,
271
272
  automation_tag, credits_reward, source, source_ref, value_summary,
272
273
  kind, discipline, parallel_safe, newcomer_friendly, needs_migration,
273
- security_sensitive, requires_rank, module_key, goal_id, created_by, promoted_at, needs)
274
+ security_sensitive, requires_rank, module_key, goal_id, created_by, promoted_at, needs, short_name)
274
275
  VALUES ($1, $2, $3, $4, $5,
275
276
  $6, $7, $8, $9,
276
277
  $10, $11, $12, $13, $14,
277
278
  $15, $16, $17, $18, $19,
278
279
  $20, $21, $22, $23, $24,
279
- CASE WHEN $4 IN ('ready','active') THEN now() ELSE NULL END, $25)
280
+ CASE WHEN $4 IN ('ready','active') THEN now() ELSE NULL END, $25, $26)
280
281
  RETURNING *`,
281
282
  [
282
283
  versionId,
@@ -303,7 +304,7 @@ async function createTask({
303
304
  moduleKey ?? null,
304
305
  goalIdEffective ?? null,
305
306
  createdBy ?? null,
306
- Array.isArray(needs) ? needs : [],
307
+ Array.isArray(needs) ? needs : [], deriveTaskName(title, description), // $26: the short name (task 1004958)
307
308
  ]
308
309
  );
309
310
  // task 1677 / ADR 0096: audit each creation-time auto-assignment so the trail is
@@ -486,7 +487,6 @@ async function updateTaskPriority(id, priority) { return updateTaskColumn(id, 'p
486
487
  // keeps on every other value_summary write.
487
488
  async function updateTaskValueSummary(id, valueSummary) { return updateTaskColumn(id, 'value_summary', valueSummary); }
488
489
 
489
-
490
490
  async function updateTaskDiscipline(id, discipline) { return updateTaskColumn(id, 'discipline', discipline); }
491
491
 
492
492
  // #360 / ADR 0018 + V3.R15 #239: open (or close) a task to Xenos claims by
@@ -0,0 +1,50 @@
1
+ -- lifecycle_019_pay_type_gates.sql — task 1004991 (goal 1000092; ADR 0385, "Pay-type
2
+ -- gates").
3
+ --
4
+ -- A pay type becomes switchable only after its gate is cleared and recorded. A
5
+ -- gate is a short list of requirements per pay type (the list lives in
6
+ -- modules/lifecycle/pay-type-gates.js, taken from the ADR's table); this table
7
+ -- records, for each requirement, who said it is met, when, and the evidence they
8
+ -- point at. The gate is cleared when every requirement's NEWEST row says 'cleared'.
9
+ -- The instance is the project, so there is no project column: one database, one
10
+ -- project.
11
+ --
12
+ -- A LIFECYCLE MIGRATION, NOT A CORE ONE: a brand-new table the lifecycle module
13
+ -- owns (pay-type-gates.js is its only reader and writer), the lifecycle_018_pay_terms
14
+ -- precedent.
15
+ --
16
+ -- RECORDS ONLY. Nothing here moves money. Clearing a requirement is a person's act
17
+ -- (often with counsel) and the evidence column holds only a REFERENCE to it (a
18
+ -- document title, a ticket, a processor verdict id) — never identity, tax or KYC
19
+ -- data, which Bongos does not store.
20
+ --
21
+ -- APPEND-ONLY. Every clearing or revoking is a new row; the newest row per
22
+ -- (pay_type, requirement) is current, and a row trigger refuses UPDATE and DELETE,
23
+ -- so the table is its own audit trail. The database also refuses outright any row for
24
+ -- eu_labour: until a later ADR enables it, no code path, however forged, can clear it.
25
+
26
+ CREATE TABLE IF NOT EXISTS lifecycle_pay_type_gates (
27
+ id bigserial PRIMARY KEY,
28
+ pay_type text NOT NULL CHECK (pay_type IN
29
+ ('cash', 'equity', 'module_share', 'credit_pool', 'non_monetary', 'self_managed')),
30
+ requirement text NOT NULL CHECK (char_length(requirement) BETWEEN 1 AND 64),
31
+ state text NOT NULL CHECK (state IN ('cleared', 'revoked')),
32
+ evidence_ref text NOT NULL CHECK (char_length(evidence_ref) BETWEEN 1 AND 500),
33
+ decided_by bigint NOT NULL REFERENCES builders(id),
34
+ decided_at timestamptz NOT NULL DEFAULT now()
35
+ );
36
+
37
+ -- The current state of a requirement: newest first.
38
+ CREATE INDEX IF NOT EXISTS lifecycle_pay_type_gates_current
39
+ ON lifecycle_pay_type_gates (pay_type, requirement, decided_at DESC, id DESC);
40
+
41
+ CREATE OR REPLACE FUNCTION lifecycle_pay_type_gates_refuse_change() RETURNS trigger AS $$
42
+ BEGIN
43
+ RAISE EXCEPTION 'pay-type gates are a history: % on % is refused; record a new row instead', TG_OP, TG_TABLE_NAME
44
+ USING ERRCODE = 'check_violation';
45
+ END;
46
+ $$ LANGUAGE plpgsql;
47
+
48
+ DROP TRIGGER IF EXISTS lifecycle_pay_type_gates_append_only ON lifecycle_pay_type_gates;
49
+ CREATE TRIGGER lifecycle_pay_type_gates_append_only BEFORE UPDATE OR DELETE ON lifecycle_pay_type_gates
50
+ FOR EACH ROW EXECUTE FUNCTION lifecycle_pay_type_gates_refuse_change();
@@ -29,6 +29,7 @@
29
29
  "kickoff-seed",
30
30
  "rot",
31
31
  "project-settings",
32
+ "pay-type-gates",
32
33
  "artist-gate",
33
34
  "removal-proposals",
34
35
  "sky",
@@ -0,0 +1,180 @@
1
+ // modules/lifecycle/pay-type-gates.js — a pay type may be put on a task only after its
2
+ // gate is cleared for the project (task 1004991, goal 1000092; ADR 0385 "Pay-type gates").
3
+ //
4
+ // A gate is a fixed list of requirements per pay type, taken from the ADR's table and
5
+ // written down here as data. A person records each requirement as cleared (with a
6
+ // reference to the evidence) or revoked; the gate is cleared when every requirement's
7
+ // newest record says cleared. The pay-terms write paths ask `refuseUnclearedGates`
8
+ // before they write, so the server refuses a pay term whose type has an open gate.
9
+ //
10
+ // FOUR RULES THIS FILE HOLDS:
11
+ // 1. The list is data, not prose: REQUIREMENTS is the one place the ADR's table lives
12
+ // in code, and the table pins it against the pay-type list pay-terms.js accepts.
13
+ // 2. eu_labour can never be cleared. It is not in any list a write accepts and the
14
+ // table's CHECK refuses a row for it, so no request, however forged, reaches a
15
+ // cleared state (a later ADR enables it, per ADR 0385's EU labour row).
16
+ // 3. self_managed clears with one act, the acknowledgement that Bongos moves and
17
+ // guarantees nothing. Nothing else is asked of it.
18
+ // 4. Records only. Evidence is a REFERENCE a person typed; Bongos stores no identity,
19
+ // tax or KYC data here, and nothing in this file moves money.
20
+ //
21
+ // Who may record a requirement is decided by the ROUTE (the project owner, on the
22
+ // server — ADR 0016); who recorded it comes from the signed-in builder, never the body.
23
+
24
+ const api = require('../../src/module-api');
25
+ const { PAY_TYPES, SELF_MANAGED_LABEL } = require('./pay-terms');
26
+
27
+ const NEVER_CLEARABLE = ['eu_labour'];
28
+ const ACKNOWLEDGEMENT = 'bongos_moves_and_guarantees_nothing';
29
+ const EVIDENCE_MAX = 500;
30
+
31
+ // The machine-checkable list per pay type (ADR 0385, "Must exist first").
32
+ const REQUIREMENTS = {
33
+ cash: [
34
+ ['processor_onboarding', 'Processor onboarding done'],
35
+ ['tax_details', 'Tax details collected'],
36
+ ['age_check', 'Age check passed'],
37
+ ['sanctions_screening', 'Sanctions screening done'],
38
+ ['signed_rule', 'Signed pay rule on file'],
39
+ ],
40
+ equity: [
41
+ ['counsel_approved_structure', 'The project\'s own counsel-approved structure'],
42
+ ['one_signature_per_grant', 'One signature per grant'],
43
+ ['no_percentage_fee', 'No percentage fee taken'],
44
+ ['wording_filter', 'Credit and valuation wording filter in force'],
45
+ ],
46
+ module_share: [
47
+ ['standard_module_licence', 'Standard module licence'],
48
+ ['takedown_contact', 'Takedown contact named'],
49
+ ['payee_onboarding', 'Payee onboarding done'],
50
+ ],
51
+ credit_pool: [
52
+ ['credit_wording_in_force', 'Credit wording rule (ADR 0385 decision 9) in force'],
53
+ ['model_inputs_visible', 'Model inputs visible to the people affected'],
54
+ ['signed_rule', 'Signed pay rule on file'],
55
+ ],
56
+ non_monetary: [
57
+ ['guardian_consent_13_15', 'Guardian consent for ages 13 to 15'],
58
+ ['no_cash_redemption', 'No cash redemption of any credit'],
59
+ ],
60
+ self_managed: [
61
+ [ACKNOWLEDGEMENT, 'Acknowledged: Bongos moves and guarantees nothing'],
62
+ ],
63
+ };
64
+
65
+ function requirementIds(payType) {
66
+ return (REQUIREMENTS[payType] || []).map(([id]) => id);
67
+ }
68
+
69
+ // PURE. The verdict for one pay type, from its current rows ({ requirement, state,
70
+ // evidence_ref, decided_by, decided_at }, newest per requirement). A requirement with
71
+ // no row, or whose newest row is revoked, is missing. Rows for requirements not in the
72
+ // list are ignored, so a stray row can never clear a gate.
73
+ function gateVerdict(payType, currentRows) {
74
+ if (NEVER_CLEARABLE.includes(payType)) {
75
+ return { pay_type: payType, cleared: false, never: true, requirements: [], missing: [] };
76
+ }
77
+ const byReq = new Map((currentRows || []).filter((r) => r.pay_type == null || r.pay_type === payType).map((r) => [r.requirement, r]));
78
+ const requirements = (REQUIREMENTS[payType] || []).map(([id, label]) => {
79
+ const r = byReq.get(id);
80
+ const met = !!r && r.state === 'cleared';
81
+ return {
82
+ requirement: id,
83
+ label,
84
+ met,
85
+ evidence_ref: r ? r.evidence_ref : null,
86
+ cleared_by: met ? r.decided_by : null,
87
+ cleared_at: met ? r.decided_at : null,
88
+ };
89
+ });
90
+ const missing = requirements.filter((x) => !x.met).map((x) => x.requirement);
91
+ return { pay_type: payType, cleared: requirements.length > 0 && missing.length === 0, never: false, requirements, missing };
92
+ }
93
+
94
+ // The newest row per (pay_type, requirement) for these pay types, one read.
95
+ async function readCurrentRows(payTypes, { db } = {}) {
96
+ const exec = db || api.pool;
97
+ const { rows } = await exec.query(
98
+ `SELECT DISTINCT ON (pay_type, requirement) pay_type, requirement, state, evidence_ref, decided_by, decided_at
99
+ FROM lifecycle_pay_type_gates
100
+ WHERE pay_type = ANY($1::text[])
101
+ ORDER BY pay_type, requirement, decided_at DESC, id DESC`, [payTypes]);
102
+ return rows;
103
+ }
104
+
105
+ // Every pay type's verdict, for the owner's view (and the settings page).
106
+ async function readGates({ db } = {}) {
107
+ const rows = await readCurrentRows(PAY_TYPES, { db });
108
+ const gates = PAY_TYPES.map((p) => gateVerdict(p, rows.filter((r) => r.pay_type === p)));
109
+ for (const p of NEVER_CLEARABLE) gates.push(gateVerdict(p, []));
110
+ return gates;
111
+ }
112
+
113
+ // PURE. A verdict as a non-owner may see it: met or missing per requirement, without the
114
+ // evidence reference a person typed or who cleared it (those are the owner's own records).
115
+ function withoutEvidence(gate) {
116
+ return { ...gate, requirements: gate.requirements.map(({ evidence_ref, cleared_by, cleared_at, ...rest }) => rest) };
117
+ }
118
+
119
+ // Checks a list of normalized terms against the gates. Returns null when every type
120
+ // in it is cleared, else { code, message, pay_type, missing } for the first that is
121
+ // not. One read however many terms there are, and none for an empty list.
122
+ async function refuseUnclearedGates(terms, { db } = {}) {
123
+ const types = [...new Set((terms || []).map((t) => t.pay_type))];
124
+ if (!types.length) return null;
125
+ const rows = await readCurrentRows(types, { db });
126
+ for (const payType of types) {
127
+ const v = gateVerdict(payType, rows.filter((r) => r.pay_type === payType));
128
+ if (!v.cleared) {
129
+ return {
130
+ code: 'pay_type_gate_not_cleared',
131
+ pay_type: payType,
132
+ missing: v.missing,
133
+ message: `The ${payType} pay type is not switched on for this project: its gate is not cleared (still needed: ${v.missing.join(', ')}).`,
134
+ };
135
+ }
136
+ }
137
+ return null;
138
+ }
139
+
140
+ // PURE. Checks a request body to record one requirement. Returns { state, evidenceRef }
141
+ // or { status, code, error }. The recorder is never read from the body.
142
+ function normalizeGateWrite(payType, requirement, body) {
143
+ if (NEVER_CLEARABLE.includes(payType)) {
144
+ return { status: 409, code: 'pay_type_never_clearable', error: `${payType} cannot be cleared; it stays off until a later decision enables it.` };
145
+ }
146
+ if (!PAY_TYPES.includes(payType)) return { status: 404, code: 'unknown_pay_type', error: `pay type must be one of ${PAY_TYPES.join(', ')}.` };
147
+ if (!requirementIds(payType).includes(requirement)) {
148
+ return { status: 404, code: 'unknown_requirement', error: `${payType} has no such requirement; it has ${requirementIds(payType).join(', ')}.` };
149
+ }
150
+ const b = body && typeof body === 'object' && !Array.isArray(body) ? body : {};
151
+ const extra = Object.keys(b).filter((k) => !['state', 'evidence_ref', 'acknowledged'].includes(k));
152
+ if (extra.length) return { status: 400, code: 'unknown_field', error: `unknown field: ${extra.join(', ')}.` };
153
+ if (b.state !== 'cleared' && b.state !== 'revoked') return { status: 400, code: 'bad_state', error: 'state is "cleared" or "revoked".' };
154
+ if (requirement === ACKNOWLEDGEMENT && b.state === 'cleared') {
155
+ if (b.acknowledged !== true) {
156
+ return { status: 400, code: 'acknowledgement_required', error: `Send acknowledged: true. It records: ${SELF_MANAGED_LABEL}` };
157
+ }
158
+ return { state: 'cleared', evidenceRef: SELF_MANAGED_LABEL };
159
+ }
160
+ const ev = typeof b.evidence_ref === 'string' ? b.evidence_ref.trim() : '';
161
+ if (!ev || ev.length > EVIDENCE_MAX) {
162
+ return { status: 400, code: 'bad_evidence_ref', error: `evidence_ref is a reference to the evidence (or the reason, for a revoke), 1 to ${EVIDENCE_MAX} characters.` };
163
+ }
164
+ return { state: b.state, evidenceRef: ev };
165
+ }
166
+
167
+ // Appends one record. Returns the gate's new verdict for that pay type.
168
+ async function recordRequirement({ payType, requirement, state, evidenceRef, builderId }, { db } = {}) {
169
+ const exec = db || api.pool;
170
+ await exec.query(
171
+ `INSERT INTO lifecycle_pay_type_gates (pay_type, requirement, state, evidence_ref, decided_by)
172
+ VALUES ($1, $2, $3, $4, $5)`, [payType, requirement, state, evidenceRef, builderId]);
173
+ const rows = await readCurrentRows([payType], { db: exec });
174
+ return gateVerdict(payType, rows);
175
+ }
176
+
177
+ module.exports = {
178
+ REQUIREMENTS, ACKNOWLEDGEMENT,
179
+ requirementIds, gateVerdict, readGates, refuseUnclearedGates, normalizeGateWrite, recordRequirement, withoutEvidence,
180
+ };
@@ -981,6 +981,34 @@ async function runCraftAchievementBackfill(deps = {}) {
981
981
  }
982
982
  }
983
983
 
984
+ // task 1004958: name the tasks that have no short name yet — every task created
985
+ // before names existed, and any row a path outside createTask inserted. Pure DB,
986
+ // idempotent (it writes only `short_name IS NULL` rows), and bounded: at most
987
+ // TASK_NAME_BATCHES batches a run, so the first run on a big board drains it in
988
+ // one go and every later run is one indexed read that finds next to nothing.
989
+ const TASK_NAME_BATCHES = 20;
990
+ async function runTaskNameBackfill(deps = {}) {
991
+ const { logFn, errFn } = loggers(deps);
992
+ const tasksDb = (deps.tasksDb && typeof deps.tasksDb.backfillTaskNames === 'function')
993
+ ? deps.tasksDb
994
+ : require('./task-name-db.js');
995
+ const exec = deps.pool || seams.pool;
996
+ let named = 0;
997
+ try {
998
+ for (let i = 0, after = 0; i < TASK_NAME_BATCHES; i++) {
999
+ const out = await tasksDb.backfillTaskNames({ exec, after });
1000
+ named += out.named;
1001
+ after = out.next;
1002
+ if (!out.scanned) break;
1003
+ }
1004
+ if (named) logFn(`[publish-reconciler] task names: ${named} task(s) named`);
1005
+ return { named };
1006
+ } catch (e) {
1007
+ errFn(`[publish-reconciler] task name backfill (non-blocking): ${e && e.message}`);
1008
+ return { skipped: 'error', error: e && e.message, named };
1009
+ }
1010
+ }
1011
+
984
1012
  // The sweeps one tick fires. Each is INDEPENDENT on purpose: the cost (task 1366)
985
1013
  // and criterion (task 1002069) sweeps are pure DB — no push credential — and a
986
1014
  // failure in either must never touch the confirmed→shipped work.
@@ -989,7 +1017,7 @@ async function runCraftAchievementBackfill(deps = {}) {
989
1017
  // between runs; none means every tick (INTERVAL_MS). The publish, cost and
990
1018
  // criterion sweeps keep the tick — a strand, an unpriced session and a newly
991
1019
  // satisfiable criterion are all things someone is waiting on. The promotion sweep
992
- // and the craft backfill run HOURLY: the ship-time triggers already promote and
1020
+ // and the craft and task-name backfills run HOURLY: the ship-time triggers already promote and
993
1021
  // unlock on the event, so these are backlog drains, and a drain an hour late
994
1022
  // costs nobody anything while running it every 5 minutes cost every instance
995
1023
  // ~288 scans a day.
@@ -1000,6 +1028,7 @@ const SWEEPS = [
1000
1028
  ['criterion sweep', runCriterionSweep],
1001
1029
  ['promotion sweep', runPromotionSweep, HOURLY_MS],
1002
1030
  ['craft achievement backfill', runCraftAchievementBackfill, HOURLY_MS],
1031
+ ['task name backfill', runTaskNameBackfill, HOURLY_MS],
1003
1032
  ];
1004
1033
 
1005
1034
  // buildTick — one interval round, with a per-sweep overlap guard (task 1004924).
@@ -1083,6 +1112,7 @@ module.exports = {
1083
1112
  runPromotionSweep,
1084
1113
  runCriterionSweep,
1085
1114
  runCraftAchievementBackfill,
1115
+ runTaskNameBackfill,
1086
1116
  buildTick,
1087
1117
  couldBeStaleGenerated,
1088
1118
  describeTarget,
@@ -70,8 +70,9 @@ function splitRebaseGate(rows, { limit = gateLimit() } = {}) {
70
70
  // the hint and the CLI can say "waiting, not on you".
71
71
  function rebaseRefusalTasks(split) {
72
72
  return [
73
- ...split.gating.map((t) => ({ id: t.id, title: t.title, reason: t.needs_rebase_reason })),
74
- ...split.waiting.map((t) => ({ id: t.id, title: t.title, reason: t.needs_rebase_reason, waiting: true, waits_on: t.waits_on })),
73
+ // task 1004958: short_name rides along so every surface can write "task N - name".
74
+ ...split.gating.map((t) => ({ id: t.id, short_name: t.short_name || null, title: t.title, reason: t.needs_rebase_reason })),
75
+ ...split.waiting.map((t) => ({ id: t.id, short_name: t.short_name || null, title: t.title, reason: t.needs_rebase_reason, waiting: true, waits_on: t.waits_on })),
75
76
  ];
76
77
  }
77
78
 
@@ -100,12 +100,13 @@ function taskNotReadyHint(taskId, currentStatus) {
100
100
  // task 1004788: only the FIXABLE tasks gate (rebase-gate.js); a task set aside
101
101
  // because it waits on something outside the builder is listed after them as
102
102
  // "waiting, not on you" so nobody chases it.
103
+ const { taskRef } = require('../task-name.js'); // task 1004958
103
104
  function rebaseRequiredHint(rebaseTasks) {
104
105
  const all = rebaseTasks || [];
105
- const ids = all.filter((t) => !t.waiting).map((t) => `task ${t.id}`);
106
+ const ids = all.filter((t) => !t.waiting).map((t) => taskRef(t.id, t.short_name));
106
107
  const waiting = all.filter((t) => t.waiting);
107
108
  const aside = waiting.length
108
- ? ` Waiting, not on you (set aside; they land on their own when what they wait on clears): ${waiting.map((t) => `task ${t.id} (${t.waits_on || 'outside you'})`).join('; ')}.`
109
+ ? ` Waiting, not on you (set aside; they land on their own when what they wait on clears): ${waiting.map((t) => `${taskRef(t.id, t.short_name)} (${t.waits_on || 'outside you'})`).join('; ')}.`
109
110
  : '';
110
111
  return `Your queue is gated: ${ids.length} confirmed task(s) of yours are waiting to land and need you (${ids.join(', ') || 'unknown'}).${aside} Clear it with /strand-fix N (works on any machine), or bring the branch up to date with \`git merge origin/main\` and re-ship — MERGE, never rebase: server-mediated publish is append-only, and rewriting a published branch makes it un-pushable (task 1002788). /merge-mode only if this machine has a GitHub push credential. To give up on one instead, /builder-release it. If CI is simply backed up, waiting is correct: a task already landed clears the gate on its own. If main's CI is red for a reason outside your branch, the fix is still claimable: a P1 kind=infra task passes this gate (task 1004370).`;
111
112
  }
@@ -0,0 +1,59 @@
1
+ 'use strict';
2
+
3
+ // modules/lifecycle/routes/pay-type-gates.js — read, and record, the gate each pay type
4
+ // must clear before it can be put on a task (task 1004991, goal 1000092; ADR 0385
5
+ // "Pay-type gates"). The rules live in ../pay-type-gates.js; this file is the doorway.
6
+ //
7
+ // GET /pay-type-gates — every pay type's gate: each requirement, whether it is met, the
8
+ // evidence reference, and who cleared it and when. eu_labour is listed as never clearable.
9
+ // PUT /pay-type-gates/:payType/:requirement — body { state: 'cleared'|'revoked',
10
+ // evidence_ref } (or { state: 'cleared', acknowledged: true } for the self-managed
11
+ // acknowledgement). Appends one record; the recorder is the signed-in owner, never the
12
+ // body. Clearing a real gate is a person's act, so nothing here clears one by itself.
13
+ //
14
+ // MODULE BOUNDARY: reaches core only through `../../../src/module-api`.
15
+
16
+ const express = require('express');
17
+ const api = require('../../../src/module-api');
18
+ const gates = require('../pay-type-gates');
19
+
20
+ const log = api.logger('lifecycle');
21
+
22
+ module.exports = function buildPayTypeGatesRouter() {
23
+ const router = express.Router();
24
+
25
+ // rank: any-builder for met/missing (the same pay information `GET /pay-terms` shows, no person's
26
+ // money); the evidence references and who cleared each requirement are the owner's records, so
27
+ // they come back for an archon only.
28
+ router.get('/pay-type-gates', api.requireBuilder, async (req, res) => {
29
+ try {
30
+ const all = await gates.readGates();
31
+ res.json({ gates: req.builder.rank === 'archon' ? all : all.map(gates.withoutEvidence) });
32
+ } catch (err) {
33
+ log.error(`GET /pay-type-gates: ${err && err.message}`);
34
+ res.fail('pay_type_gates_read_failed', { status: 500, message: 'internal error' });
35
+ }
36
+ });
37
+
38
+ // rank: payout.approve (archon floor). Whether a pay type is legal for this project is the
39
+ // owner's call, often with counsel, and it is the same authority that approves payouts — no new
40
+ // atom is minted for it (ADR 0016: enforced here, on the server).
41
+ router.put('/pay-type-gates/:payType/:requirement', api.requireBuilder, api.requirePermission('payout.approve'), async (req, res) => {
42
+ const { payType, requirement } = req.params;
43
+ // Unknown fields (a forged cleared_by, say) are refused here; the values are checked by the lib.
44
+ if (api.validateOrRespond(req, res, { state: {}, evidence_ref: {}, acknowledged: {} })) return;
45
+ const parsed = gates.normalizeGateWrite(payType, requirement, req.body);
46
+ if (parsed.error) return res.fail(parsed.code, { status: parsed.status, message: parsed.error });
47
+ try {
48
+ const gate = await gates.recordRequirement({
49
+ payType, requirement, state: parsed.state, evidenceRef: parsed.evidenceRef, builderId: req.builder.id,
50
+ });
51
+ res.json({ ok: true, gate });
52
+ } catch (err) {
53
+ log.error(`PUT /pay-type-gates: ${err && err.message}`);
54
+ res.fail('pay_type_gates_write_failed', { status: 500, message: 'internal error' });
55
+ }
56
+ });
57
+
58
+ return router;
59
+ };
@@ -35,6 +35,7 @@ const api = require('../../../src/module-api');
35
35
  const goalCategory = require('../goal-category');
36
36
  const autopickup = require('../autopickup');
37
37
  const payTermsLib = require('../pay-terms'); // task 1004990
38
+ const payTypeGates = require('../pay-type-gates'); // task 1004991
38
39
 
39
40
  const log = api.logger('lifecycle');
40
41
 
@@ -551,6 +552,9 @@ module.exports = function buildProjectSettingsRouter() {
551
552
  const parsed = payTermsLib.normalizePayTerms(req.body.terms);
552
553
  if (parsed.error) return res.fail('bad_pay_terms', { status: 400, message: parsed.error });
553
554
  try {
555
+ // task 1004991: the project's standing terms obey the same gate as a task's (ADR 0385).
556
+ const gateRefusal = await payTypeGates.refuseUnclearedGates(parsed.terms);
557
+ if (gateRefusal) return res.fail(gateRefusal.code, { status: 409, message: gateRefusal.message });
554
558
  await payTermsLib.setPayTerms({ taskId: null, terms: parsed.terms, builderId: req.builder.id });
555
559
  const policy = await payTermsLib.readProjectPayTerms();
556
560
  res.json({ ok: true, row: payTermsRow(policy) });
@@ -29,6 +29,9 @@ const { validateOrRespond, LIMITS, parseId } = api;
29
29
  const { badKind, badNeeds, detectsMigration } = require('./task-route-helpers.js');
30
30
  const { describeCheckViolation } = require('../pg-constraint-fail.js');
31
31
  const payTermsLib = require('../pay-terms.js'); // task 1004990
32
+ const payTypeGates = require('../pay-type-gates.js'); // task 1004991
33
+ const taskName = require('../task-name.js'); // task 1004958
34
+ const { updateTaskShortName } = require('../task-name-db.js');
32
35
  const { goalNotOpenRefusal } = require('./goal-route-authz.js');
33
36
  const { resolveUpkeepHome, isUpkeepFiling, UPKEEP_HINT } = require('../upkeep-home.js'); // task 1004597
34
37
  // task 1003208: structured logging (pino via the doorway) — was console.*.
@@ -332,6 +335,37 @@ module.exports = function registerTaskWriteRoutes(router) {
332
335
  }
333
336
  });
334
337
 
338
+ // PATCH /tasks/:id/short-name { short_name } — rename a task's 2-3 word name, the
339
+ // one sessions print as "task N - <name>" (task 1004958). Its own route because the
340
+ // owner's rule is that the task's AUTHOR may change it, and PATCH /tasks/:id is gated
341
+ // on task.edit, which an author below Metic does not hold. The name is normalised
342
+ // to task-name.js's alphabet (three lowercase words at most), so what reaches the
343
+ // startup notes is a topic and nothing else; the table CHECK holds the same shape.
344
+ // rank: any-builder, narrowed in the handler — the task's author, or a Metic or Archon.
345
+ router.patch('/tasks/:id/short-name', auth.requireBuilder, async (req, res) => {
346
+ if (validateOrRespond(req, res, { short_name: { required: true, type: 'string', maxLength: 80 } })) return;
347
+ const id = parseId(req, res, { code: 'bad_task_id', positiveInt: true });
348
+ if (id === null) return;
349
+ const name = taskName.normaliseAuthoredName(req.body.short_name);
350
+ if (!name) {
351
+ return res.fail('bad_short_name', { status: 400, message: `short_name must be 1-${taskName.MAX_WORDS} plain words (letters, digits, hyphens), ${taskName.MAX_LEN} characters at most` });
352
+ }
353
+ try {
354
+ const task = await db.getTask(id);
355
+ if (!task) return res.fail('task_not_found', 404);
356
+ const isAuthor = task.created_by != null && String(task.created_by) === String(req.builder.id);
357
+ if (!isAuthor && !auth.rankMeetsThreshold(req.builder.rank, ['metic', 'archon'])) {
358
+ return res.fail('not_task_author', { status: 403, message: 'only the task\'s author (or a Metic or Archon) may rename it' });
359
+ }
360
+ const updated = await updateTaskShortName(id, name);
361
+ if (!updated) return res.fail('task_not_found', 404);
362
+ return res.json({ task: updated });
363
+ } catch (err) {
364
+ log.error('[gds] PATCH /tasks/:id/short-name', err);
365
+ return res.fail('patch_failed', { status: 500, message: 'internal error' });
366
+ }
367
+ });
368
+
335
369
  // PATCH /tasks/:id — supports body.parent_task_id (V2 C2), body.kind (V2 B1),
336
370
  // body.discipline (mig 014), body.parallel_safe
337
371
  // (mig 028), and body.newcomer_friendly (#360 / ADR 0018 + V3.R15 #239 —
@@ -575,6 +609,15 @@ module.exports = function registerTaskWriteRoutes(router) {
575
609
  payTerms = payTermsLib.normalizePayTerms(body.pay_terms);
576
610
  if (payTerms.error) return res.fail('bad_pay_terms', { status: 400, message: payTerms.error });
577
611
  if (!(await db.getTask(id))) return res.fail('task_not_found', 404);
612
+ // task 1004991: a pay type goes on a task only once its gate is cleared (ADR 0385).
613
+ let gateRefusal;
614
+ try {
615
+ gateRefusal = await payTypeGates.refuseUnclearedGates(payTerms.terms);
616
+ } catch (err) {
617
+ log.error('[gds] PATCH /tasks/:id pay_terms gate read', err);
618
+ return res.fail('patch_failed', { status: 500, message: 'internal error' });
619
+ }
620
+ if (gateRefusal) return res.fail(gateRefusal.code, { status: 409, message: gateRefusal.message });
578
621
  }
579
622
  // ---- all gates passed: apply the writes. Order preserved from the old ----
580
623
  // shape — requires_rank still runs AFTER security_sensitive so a
@@ -19,6 +19,7 @@
19
19
  // emitCard() is the one I/O helper the CLIs share (print monospace + write HTML +
20
20
  // print the [otb-card-html] marker the skills pick up).
21
21
 
22
+ const { safeTaskName, taskRef } = require('./task-name.js'); // task 1004958
22
23
  const fs = require('node:fs');
23
24
  const os = require('node:os');
24
25
  const path = require('node:path');
@@ -523,6 +524,8 @@ function buildCardModel(input = {}) {
523
524
 
524
525
  return {
525
526
  stage, cfg, brand, taskId,
527
+ // task 1004958: the task's 2-3 word name, so the confirm line reads "task N - name".
528
+ shortName: safeTaskName(input.shortName),
526
529
  title: input.title || '',
527
530
  value,
528
531
  notes: input.notes || '',
@@ -751,7 +754,7 @@ function sanitizeOneLine(s) {
751
754
 
752
755
  function confirmLine(model) {
753
756
  const m = model || {};
754
- const id = m.taskId != null ? `task ${m.taskId}` : 'task';
757
+ const id = m.taskId != null ? taskRef(m.taskId, m.shortName) : 'task';
755
758
  const word = (m.cfg && m.cfg.statusWord) ? String(m.cfg.statusWord).toLowerCase() : String(m.stage || 'done');
756
759
  // The stage's own rows already hold exactly the facts that stage cares about
757
760
  // (buildCardModel picks them per stage); reuse them rather than re-deriving,
@@ -0,0 +1,55 @@
1
+ 'use strict';
2
+ // modules/lifecycle/task-name-db.js — the two writes behind a task's short name
3
+ // (task 1004958): the author's rename and the backfill of unnamed rows.
4
+ //
5
+ // WHY ITS OWN FILE. createTask (db-tasks.js) writes the name on every new task;
6
+ // these two are the rest of the feature, and db-tasks.js sits at the 1,500-line
7
+ // ratchet. The name itself is derived ONLY in task-name.js, so a backfilled name
8
+ // and a born-with name can never disagree.
9
+
10
+ const api = require('../../src/module-api');
11
+ const { pool } = api;
12
+ const { deriveTaskName } = require('./task-name.js');
13
+
14
+ // The author's rename. The caller passes a name already through
15
+ // task-name.normaliseAuthoredName; the table CHECK holds the same shape.
16
+ async function updateTaskShortName(id, shortName, exec = pool) {
17
+ const { rows } = await exec.query(
18
+ 'UPDATE tasks SET short_name = $1, updated_at = now() WHERE id = $2 RETURNING *',
19
+ [shortName, id]
20
+ );
21
+ return rows[0] ?? null;
22
+ }
23
+
24
+ // Name the tasks created before names existed (or by a path that skipped
25
+ // createTask). Reads a batch of unnamed rows, derives each name in JS and writes
26
+ // them in one statement; `short_name IS NULL` on the write too, so an author's
27
+ // edit landing in between is never overwritten. A title with no topic word keeps
28
+ // NULL and simply prints "task N". Stateless: the caller walks forward by passing
29
+ // back `next` as `after`, so a row that stays unnamed is passed over rather than
30
+ // re-read every batch, and two callers can never disturb each other's walk. The
31
+ // publish-reconciler walks from 0 once an hour.
32
+ async function backfillTaskNames({ after = 0, limit = 500, exec = pool } = {}) {
33
+ const { rows } = await exec.query(
34
+ 'SELECT id, title, description FROM tasks WHERE short_name IS NULL AND id > $1 ORDER BY id LIMIT $2',
35
+ [after, limit]
36
+ );
37
+ if (!rows.length) return { named: 0, scanned: 0, next: after };
38
+ const next = Number(rows[rows.length - 1].id);
39
+ const ids = [];
40
+ const names = [];
41
+ for (const r of rows) {
42
+ const name = deriveTaskName(r.title, r.description);
43
+ if (name) { ids.push(r.id); names.push(name); }
44
+ }
45
+ if (!ids.length) return { named: 0, scanned: rows.length, next };
46
+ const res = await exec.query(
47
+ `UPDATE tasks t SET short_name = v.name
48
+ FROM unnest($1::bigint[], $2::text[]) AS v(id, name)
49
+ WHERE t.id = v.id AND t.short_name IS NULL`,
50
+ [ids, names]
51
+ );
52
+ return { named: res.rowCount || 0, scanned: rows.length, next };
53
+ }
54
+
55
+ module.exports = { updateTaskShortName, backfillTaskNames };