@bongos/core 1.19.550 → 1.19.551

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.
@@ -0,0 +1,64 @@
1
+ -- core_232_task_merge_provenance.sql — give a task-triage MERGE verdict
2
+ -- somewhere to record what it decided (task 1001478).
3
+ --
4
+ -- Bugs now land directly as kind=bug tasks with no triage gate (task 1003071),
5
+ -- so near-duplicate bug reports accumulate with nothing to record "this is the
6
+ -- same report as #N". idea_inbox solved the identical problem for ideas in
7
+ -- core_195 (`merged_into_idea_id` + `triage_note`); this is the same shape for
8
+ -- `tasks`, reusing the SAME column names so a reader who knows one surface
9
+ -- already knows the other.
10
+ --
11
+ -- Deliberately NOT a new `tasks.status` value. Adding `'merged'` to the status
12
+ -- CHECK would touch every place that already enumerates task statuses
13
+ -- (VOTABLE_TASK_STATUSES, the claimable feed, dependency satisfaction, the
14
+ -- version-close gate, the diagrams generator's state machine) for a distinction
15
+ -- that is really just "abandoned, and here is which task absorbed it". A merged
16
+ -- task instead reuses the existing terminal `abandoned` status — it already
17
+ -- means "retired, not shippable, dependents on it are stranded by owner ruling"
18
+ -- (modules/lifecycle/CLAUDE.md), which is exactly what a merged-away duplicate
19
+ -- is — and `merged_into_task_id` carries the one thing `abandoned` alone can't:
20
+ -- which task is now the canonical one.
21
+ --
22
+ -- CORE, not modules/lifecycle/migrations/: `tasks` is a CORE table (created
23
+ -- before the module system), and a module migration may only touch its own
24
+ -- <key>_-prefixed objects — fitness.js checkModuleMigrationsAdditive hard-fails
25
+ -- an `ALTER TABLE tasks` from a module (ADR 0083 Decision #5).
26
+ --
27
+ -- Two additive, nullable columns + an index. Forward-safe by construction:
28
+ -- every existing reader SELECTs an explicit column list, so old code on the new
29
+ -- schema neither sees nor needs these (tests/migrations_forward_safe.mjs).
30
+
31
+ BEGIN;
32
+
33
+ ALTER TABLE tasks
34
+ ADD COLUMN IF NOT EXISTS merged_into_task_id bigint REFERENCES tasks(id);
35
+
36
+ ALTER TABLE tasks
37
+ ADD COLUMN IF NOT EXISTS triage_note text;
38
+
39
+ -- A target exists IFF the task is abandoned. Mirrors idea_inbox's
40
+ -- merge_target_needs_merged_status guard (core_195) one status value later:
41
+ -- 'abandoned' is where a normal (non-merge) retirement already lands, so this
42
+ -- CHECK only rules out the shape a merge target with a still-live task behind
43
+ -- it would produce — never a normal abandon, which sets no target at all.
44
+ DO $$
45
+ BEGIN
46
+ IF NOT EXISTS (
47
+ SELECT 1 FROM pg_constraint
48
+ WHERE conrelid = 'tasks'::regclass
49
+ AND conname = 'tasks_merge_target_needs_abandoned_status'
50
+ ) THEN
51
+ ALTER TABLE tasks
52
+ ADD CONSTRAINT tasks_merge_target_needs_abandoned_status
53
+ CHECK (merged_into_task_id IS NULL OR status = 'abandoned');
54
+ END IF;
55
+ END $$;
56
+
57
+ CREATE INDEX IF NOT EXISTS tasks_merged_into_task_id_idx
58
+ ON tasks (merged_into_task_id)
59
+ WHERE merged_into_task_id IS NOT NULL;
60
+
61
+ INSERT INTO schema_migrations (version) VALUES ('core_232_task_merge_provenance')
62
+ ON CONFLICT DO NOTHING;
63
+
64
+ COMMIT;
@@ -848,6 +848,8 @@ function createClient(opts = {}) {
848
848
  deleteTasksIdDependenciesDepId: (args) => request("DELETE", "/tasks/{id}/dependencies/{depId}", { hasBody: false }, args),
849
849
  // POST /tasks/{id}/grade — rank: any-builder — POST /tasks/:id/grade
850
850
  postTasksIdGrade: (args) => request("POST", "/tasks/{id}/grade", { hasBody: true }, args),
851
+ // POST /tasks/{id}/merge — rank: metic+archon — POST /tasks/:id/merge
852
+ postTasksIdMerge: (args) => request("POST", "/tasks/{id}/merge", { hasBody: true }, args),
851
853
  // POST /tasks/{id}/override-request — rank: any-builder — POST /tasks/:id/override-request
852
854
  postTasksIdOverrideRequest: (args) => request("POST", "/tasks/{id}/override-request", { hasBody: true }, args),
853
855
  // POST /tasks/{id}/promote — rank: metic+archon — POST /tasks/:id/promote
@@ -137,6 +137,7 @@ const PERMISSIONS = [
137
137
  { key: 'task.promote', system: false, floor: 'metic', guards: 'POST /tasks/:id/promote' },
138
138
  { key: 'task.demote', system: false, floor: 'metic', guards: 'POST /tasks/:id/demote' },
139
139
  { key: 'task.abandon', system: false, floor: 'metic', guards: 'POST /tasks/:id/abandon — scope removal; undo = POST /tasks/:id/promote (ADR 0152, was archon)' },
140
+ { key: 'task.merge', system: false, floor: 'metic', guards: 'POST /tasks/:id/merge — mark a duplicate task merged into another (task 1001478, the idea.triage merge analogue)' },
140
141
  { key: 'task.vote', system: false, floor: 'metic', guards: 'POST /tasks/:id/vote' },
141
142
  { key: 'task.dependency.manage', system: false, floor: 'metic', guards: 'POST|PUT|DELETE /tasks/:id/dependencies (same-tier)' },
142
143
  { key: 'task.criterion.manage', system: false, floor: 'metic', guards: 'POST|DELETE /tasks/:id/criteria' },
@@ -0,0 +1,38 @@
1
+ -- government_011_task_merge_metic.sql — grant the new `task.merge` atom to the
2
+ -- Metic and Archon rank-roles (task 1001478).
3
+ --
4
+ -- A BRAND-NEW key: no rank holds it yet, so BOTH rungs the floor implies are
5
+ -- granted here. RANK_SEED is a cumulative superset — floor `metic` means
6
+ -- {metic, archon} — and the drift guard (tests/government_seed.mjs) compares
7
+ -- the UNION of the run-once seed plus every later grant migration against
8
+ -- that floor-derived seed, per rank. Minting the catalog row WITHOUT this
9
+ -- migration is exactly what that guard catches.
10
+ --
11
+ -- Mirrors government_010_project_settings_manage.sql, NOT a `governance_*`
12
+ -- file — those predate government_001_rename_from_governance, which renamed
13
+ -- `governance_role_permissions` -> `government_rank_permissions` and
14
+ -- `role_key` -> `rank_key` with no compatibility shim.
15
+ --
16
+ -- Additive only: one new permission key granted to two ranks; nothing is
17
+ -- revoked. Idempotent (ON CONFLICT DO NOTHING); forward-safe (INSERT only).
18
+ --
19
+ -- One block PER RANK, in the government_004/009/010 shape: the drift guard
20
+ -- scans for `SELECT '<rank>', unnest(ARRAY[…])` literally, so a combined
21
+ -- `unnest(ARRAY['metic','archon']), 'key'` form parses as zero grants and
22
+ -- reds it.
23
+
24
+ BEGIN;
25
+
26
+ INSERT INTO government_rank_permissions (rank_key, permission_key)
27
+ SELECT 'metic', unnest(ARRAY[
28
+ 'task.merge'
29
+ ])
30
+ ON CONFLICT (rank_key, permission_key) DO NOTHING;
31
+
32
+ INSERT INTO government_rank_permissions (rank_key, permission_key)
33
+ SELECT 'archon', unnest(ARRAY[
34
+ 'task.merge'
35
+ ])
36
+ ON CONFLICT (rank_key, permission_key) DO NOTHING;
37
+
38
+ COMMIT;
@@ -963,9 +963,22 @@ async function restoreTask({ taskId }, deps = {}) {
963
963
  throw Object.assign(new Error('task is not abandoned'), { code: 'TASK_NOT_ABANDONED' });
964
964
  }
965
965
 
966
+ // Clearing merged_into_task_id/triage_note here is not cosmetic — it is
967
+ // what keeps this UPDATE legal. core_232's CHECK is
968
+ // `merged_into_task_id IS NULL OR status = 'abandoned'`, and this UPDATE
969
+ // sets status = 'backlog', so a restore that left a stale target would
970
+ // 23514 (constraint violation) on exactly the merged tasks this route
971
+ // exists to un-merge — mergeTask's own header names "retarget by
972
+ // restoring first" as the intended path, so restore must actually work
973
+ // on one (task 1001478 grade finding). value_summary is left alone here
974
+ // beyond the existing 'Abandoned: %' stamp: mergeTask PRESERVES whatever
975
+ // value_summary a task already had unless a human reason overwrote it, so
976
+ // there is no mechanical merge-default text to clean up on the way back.
966
977
  const { rows: updated } = await client.query(
967
978
  `UPDATE tasks
968
979
  SET status = 'backlog',
980
+ merged_into_task_id = NULL,
981
+ triage_note = NULL,
969
982
  value_summary = CASE WHEN value_summary LIKE 'Abandoned: %' THEN NULL ELSE value_summary END,
970
983
  updated_at = now()
971
984
  WHERE id = $1
@@ -988,7 +1001,141 @@ async function restoreTask({ taskId }, deps = {}) {
988
1001
  }
989
1002
  }
990
1003
 
1004
+ // mergeTask (task 1001478) — the idea_inbox merge verdict's twin for `tasks`,
1005
+ // so /bug-triage can fold a near-duplicate kind=bug report into its canonical
1006
+ // task the same way /idea-triage folds a duplicate idea (modules/ideas/inbox.js
1007
+ // updateIdea's MERGE_SELF/MERGE_TARGET_NOT_FOUND/MERGE_TARGET_MERGED guards).
1008
+ //
1009
+ // A merge is `abandonTask` with a target attached, not a new status: 'merged'
1010
+ // would need its own line in every place that already enumerates task statuses
1011
+ // (VOTABLE_TASK_STATUSES, the claimable feed, dependency satisfaction, the
1012
+ // diagrams state machine) to answer a question 'abandoned' already answers —
1013
+ // this task is retired, its dependents are stranded by owner ruling
1014
+ // (see this file's CLAUDE.md). `merged_into_task_id` (core_232) carries the one
1015
+ // thing bare abandon can't: which task absorbed it.
1016
+ //
1017
+ // Guards, checked in this order (mirrors abandonTask, then layers the merge
1018
+ // checks on top):
1019
+ // - MERGE_SELF — a task cannot merge into itself.
1020
+ // - TASK_NOT_FOUND — the task being merged away doesn't exist.
1021
+ // - CANNOT_MERGE_SHIPPED — shipped work is immutable history (abandonTask's
1022
+ // CANNOT_ABANDON_SHIPPED, same reasoning).
1023
+ // - already merged, SAME target → idempotent no-op (alreadyMerged:true).
1024
+ // - already merged, DIFFERENT target → ALREADY_MERGED (no re-merge chains —
1025
+ // retarget by restoring first, the abandon/restore precedent, not a second
1026
+ // merge call silently overwriting provenance).
1027
+ // - TASK_HAS_ACTIVE_CLAIM — a live claim means a session still owns the
1028
+ // work; release it first (same as abandonTask).
1029
+ // - MERGE_TARGET_NOT_FOUND — the canonical task doesn't exist.
1030
+ // - MERGE_TARGET_MERGED — the target is ITSELF merged elsewhere (tests the
1031
+ // target's own `merged_into_task_id` column, not
1032
+ // its status — the idea precedent's "no chains"
1033
+ // check reads the column because a status-based
1034
+ // check can be raced by an ordinary PATCH).
1035
+ //
1036
+ // The target's own status is deliberately UNCHECKED beyond that — a duplicate
1037
+ // bug report can legitimately point at a task that already shipped (the fix
1038
+ // landed before the dup was noticed), mirroring idea merge's target-status
1039
+ // silence.
1040
+ //
1041
+ // #538 test seam: a caller-supplied pool is honored ONLY under NODE_ENV==='test'
1042
+ // (matches abandonTask/restoreTask).
1043
+ async function mergeTask({ taskId, intoTaskId, reason }, deps = {}) {
1044
+ const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
1045
+ const client = await activePool.connect();
1046
+ try {
1047
+ await client.query('BEGIN');
1048
+
1049
+ if (String(intoTaskId) === String(taskId)) {
1050
+ throw Object.assign(new Error('a task cannot merge into itself'), { code: 'MERGE_SELF' });
1051
+ }
1052
+
1053
+ const { rows: taskRows } = await client.query(
1054
+ `SELECT id, title, status, merged_into_task_id FROM tasks WHERE id = $1 FOR UPDATE`,
1055
+ [taskId]
1056
+ );
1057
+ if (taskRows.length === 0) {
1058
+ throw Object.assign(new Error('task not found'), { code: 'TASK_NOT_FOUND' });
1059
+ }
1060
+ const task = taskRows[0];
1061
+ if (task.status === 'shipped') {
1062
+ throw Object.assign(new Error('cannot merge a shipped task'), { code: 'CANNOT_MERGE_SHIPPED' });
1063
+ }
1064
+ if (task.merged_into_task_id != null) {
1065
+ if (String(task.merged_into_task_id) === String(intoTaskId)) {
1066
+ await client.query('COMMIT');
1067
+ return {
1068
+ taskId, taskTitle: task.title, taskStatus: 'abandoned',
1069
+ mergedIntoTaskId: Number(task.merged_into_task_id), alreadyMerged: true,
1070
+ };
1071
+ }
1072
+ throw Object.assign(
1073
+ new Error('task is already merged into a different task'),
1074
+ { code: 'ALREADY_MERGED', merged_into: String(task.merged_into_task_id) }
1075
+ );
1076
+ }
1077
+
1078
+ const { rows: activeClaims } = await client.query(
1079
+ `SELECT id FROM claims WHERE task_id = $1 AND released_at IS NULL LIMIT 1`,
1080
+ [taskId]
1081
+ );
1082
+ if (activeClaims.length > 0) {
1083
+ throw Object.assign(new Error('task has an active claim; release it first'), { code: 'TASK_HAS_ACTIVE_CLAIM' });
1084
+ }
1085
+
1086
+ const { rows: targetRows } = await client.query(
1087
+ `SELECT id, merged_into_task_id FROM tasks WHERE id = $1`,
1088
+ [intoTaskId]
1089
+ );
1090
+ if (targetRows.length === 0) {
1091
+ throw Object.assign(new Error('merge target task not found'), { code: 'MERGE_TARGET_NOT_FOUND' });
1092
+ }
1093
+ if (targetRows[0].merged_into_task_id != null) {
1094
+ throw Object.assign(
1095
+ new Error('merge target is itself merged into another task'),
1096
+ { code: 'MERGE_TARGET_MERGED', merged_into: String(targetRows[0].merged_into_task_id) }
1097
+ );
1098
+ }
1099
+
1100
+ // value_summary PRESERVES on an empty reason, exactly like abandonTask's
1101
+ // `COALESCE(NULLIF($2,''), value_summary)` — not a synthesized "Merged
1102
+ // into #N" default. A `completed`/`confirmed` bug task can already carry
1103
+ // a real value_summary (its own shipped-value sentence), and merging it
1104
+ // with no explicit reason must not clobber that; `merged_into_task_id`
1105
+ // is the queryable source of truth for where it went (grade finding,
1106
+ // task 1001478 round 2).
1107
+ const noteText = typeof reason === 'string' ? reason.trim().slice(0, 2000) : '';
1108
+ const { rows: updated } = await client.query(
1109
+ `UPDATE tasks
1110
+ SET status = 'abandoned',
1111
+ merged_into_task_id = $2,
1112
+ triage_note = NULLIF($3, ''),
1113
+ value_summary = COALESCE(NULLIF($3, ''), value_summary),
1114
+ updated_at = now()
1115
+ WHERE id = $1
1116
+ RETURNING id, title, status`,
1117
+ [taskId, intoTaskId, noteText]
1118
+ );
1119
+
1120
+ await client.query('COMMIT');
1121
+ return {
1122
+ taskId,
1123
+ taskTitle: updated[0].title,
1124
+ taskStatus: 'abandoned',
1125
+ previousStatus: task.status,
1126
+ mergedIntoTaskId: Number(intoTaskId),
1127
+ alreadyMerged: false,
1128
+ };
1129
+ } catch (err) {
1130
+ await client.query('ROLLBACK').catch(() => {});
1131
+ throw err;
1132
+ } finally {
1133
+ client.release();
1134
+ }
1135
+ }
1136
+
991
1137
  module.exports = {
1138
+ mergeTask,
992
1139
  abandonTask,
993
1140
  confirmTask,
994
1141
  flagTaskNeedsRebase,
@@ -173,6 +173,7 @@ const {
173
173
  getBuildersRoster,
174
174
  getClaimPublishedSha,
175
175
  listConfirmedTasksForReconcile,
176
+ mergeTask,
176
177
  needsRebaseTasksForBuilder,
177
178
  releaseClaimOnBehalf,
178
179
  resolveClaim,
@@ -320,6 +321,7 @@ module.exports = {
320
321
  confirmTask,
321
322
  abandonTask,
322
323
  restoreTask, // ADR 0152 — the abandoned→backlog undo that pairs with the Metic+ abandon grant
324
+ mergeTask, // task 1001478 — the idea_inbox merge verdict's twin for tasks (/bug-triage)
323
325
  applyGrade,
324
326
  shipTask,
325
327
  normalizeShipAssurance, SHIP_ASSURANCE_KINDS, // ADR 0184 — re-exported from ./ship-assurance
@@ -664,6 +664,48 @@ module.exports = function buildTasksRouter() {
664
664
  }
665
665
  });
666
666
 
667
+ // POST /tasks/:id/merge — mark a duplicate task merged into its canonical one
668
+ // (task 1001478 — the /bug-triage counterpart to idea_inbox's merge verdict).
669
+ // Implemented as `abandonTask` plus a target (db.mergeTask, db-ship.js) rather
670
+ // than a new `tasks.status` value — see that function's header for why. Refuses
671
+ // a self-merge, a shipped task, a task with an active claim, a target that
672
+ // doesn't exist, and a target that is itself merged elsewhere (no chains);
673
+ // idempotent when re-merging into the SAME target already recorded.
674
+ // rank: metic+archon — same floor as task.abandon, which this reuses under the
675
+ // hood; a Metic who may retire a task may also say which task it duplicates.
676
+ router.post('/tasks/:id/merge', auth.requireBuilder, auth.requirePermission('task.merge'), async (req, res) => {
677
+ if (validateOrRespond(req, res, {
678
+ into_task_id: { required: true, type: 'integer', min: 1 },
679
+ reason: { type: 'string' }, // free-text triage note (sliced to 2000 below)
680
+ })) return;
681
+ const id = parseId(req, res, { code: 'bad_task_id', positiveInt: true });
682
+ if (id === null) return;
683
+ const intoTaskId = req.body.into_task_id;
684
+ const reason = typeof req.body?.reason === 'string' ? req.body.reason.slice(0, 2000) : '';
685
+ try {
686
+ const result = await db.mergeTask({ taskId: id, intoTaskId, reason });
687
+ res.json({ ok: true, ...result });
688
+ } catch (err) {
689
+ if (err.code === 'MERGE_SELF') return res.fail('merge_self', { status: 400, message: 'a task cannot merge into itself' });
690
+ if (err.code === 'TASK_NOT_FOUND') return res.fail('task_not_found', 404);
691
+ if (err.code === 'CANNOT_MERGE_SHIPPED') {
692
+ return res.fail('cannot_merge_shipped', { status: 409, message: 'shipped tasks are immutable history' });
693
+ }
694
+ if (err.code === 'ALREADY_MERGED') {
695
+ return res.fail('already_merged', { status: 409, message: `already merged into #${err.merged_into}`, merged_into: err.merged_into });
696
+ }
697
+ if (err.code === 'TASK_HAS_ACTIVE_CLAIM') {
698
+ return res.fail('task_has_active_claim', { status: 409, message: 'release the active claim before merging' });
699
+ }
700
+ if (err.code === 'MERGE_TARGET_NOT_FOUND') return res.fail('merge_target_not_found', 404);
701
+ if (err.code === 'MERGE_TARGET_MERGED') {
702
+ return res.fail('merge_target_merged', { status: 409, message: `merge target is itself merged into #${err.merged_into}`, merged_into: err.merged_into });
703
+ }
704
+ log.error('[gds] POST /tasks/:id/merge', err);
705
+ res.fail('merge_failed', { status: 500, message: 'internal error' });
706
+ }
707
+ });
708
+
667
709
  // ==== peer votes (V3.R25 #245) — criterion C2 karma signal sourcing =======
668
710
  // These three sit BEFORE the generic /tasks/:id-shaped routes that take a
669
711
  // numeric id, but Express keys on the literal path so /tasks/peer-votes/feed
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.550",
3
+ "version": "1.19.551",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.550",
9
+ "version": "1.19.551",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.550",
3
+ "version": "1.19.551",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",