@bongos/core 1.19.550 → 1.19.552

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 (34) hide show
  1. package/.bongos-core.json +69 -34
  2. package/.claude/skills/bug-triage/SKILL.md +191 -0
  3. package/clients/bongos-client/README.md +1 -1
  4. package/clients/bongos-client/bongos-client.global.js +2 -0
  5. package/clients/bongos-client/index.cjs +2 -0
  6. package/clients/bongos-client/index.d.ts +3 -0
  7. package/clients/bongos-client/index.mjs +2 -0
  8. package/docs/api/openapi.json +77 -2
  9. package/docs/api-reference.md +3 -2
  10. package/docs/file-map.md +1 -0
  11. package/docs/module-api-changelog.md +4 -0
  12. package/migrations/core_232_task_merge_provenance.sql +64 -0
  13. package/modules/dev-box/app/src/vendor/bongos-client.cjs +2 -0
  14. package/modules/government/catalog.js +1 -0
  15. package/modules/government/migrations/government_011_task_merge_metic.sql +38 -0
  16. package/modules/lifecycle/db-goals.js +2 -1
  17. package/modules/lifecycle/db-ship.js +147 -0
  18. package/modules/lifecycle/db.js +2 -0
  19. package/modules/lifecycle/done-when.js +3 -2
  20. package/modules/lifecycle/goal-rollup.js +6 -4
  21. package/modules/lifecycle/routes/tasks.js +42 -0
  22. package/modules/lifecycle/task-status.js +72 -0
  23. package/modules/lifecycle/version-close.js +2 -1
  24. package/package-lock.json +2 -2
  25. package/package.json +1 -1
  26. package/scripts/gds/bug-triage.js +292 -0
  27. package/scripts/gds/sequence.js +4 -2
  28. package/scripts/gds/triage.js +40 -21
  29. package/src/module-api.js +1 -1
  30. package/tests/abandon_task.mjs +17 -0
  31. package/tests/auto_satisfy_criteria.mjs +24 -2
  32. package/tests/bug_triage.mjs +205 -0
  33. package/tests/goal_sort_staleness.mjs +9 -3
  34. package/tests/task_merge.mjs +198 -0
@@ -15213,6 +15213,65 @@
15213
15213
  ]
15214
15214
  }
15215
15215
  },
15216
+ "/tasks/{id}/merge": {
15217
+ "post": {
15218
+ "operationId": "post_tasks_id_merge",
15219
+ "tags": [
15220
+ "tasks"
15221
+ ],
15222
+ "summary": "POST /tasks/:id/merge",
15223
+ "description": "POST /tasks/:id/merge — mark a duplicate task merged into its canonical one (task 1001478 — the /bug-triage counterpart to idea_inbox's merge verdict). Implemented as `abandonTask` plus a target (db.mergeTask, db-ship.js) rather than a new `tasks.status` value — see that function's header for why. Refuses a self-merge, a shipped task, a task with an active claim, a target that doesn't exist, and a target that is itself merged elsewhere (no chains); idempotent when re-merging into the SAME target already recorded. rank: metic+archon — same floor as task.abandon, which this reuses under the hood; a Metic who may retire a task may also say which task it duplicates.\n\n**Rank:** `metic+archon` — Metic or Archon rank (review/triage powers).\n\n**Permissions:** `task.merge` (all required).",
15224
+ "x-rank": "metic+archon",
15225
+ "x-source": "modules/lifecycle/routes/tasks.js",
15226
+ "x-permissions": [
15227
+ "task.merge"
15228
+ ],
15229
+ "parameters": [
15230
+ {
15231
+ "name": "id",
15232
+ "in": "path",
15233
+ "required": true,
15234
+ "schema": {
15235
+ "type": "string"
15236
+ },
15237
+ "description": "Path parameter `id`."
15238
+ }
15239
+ ],
15240
+ "requestBody": {
15241
+ "required": true,
15242
+ "content": {
15243
+ "application/json": {
15244
+ "schema": {
15245
+ "$ref": "#/components/schemas/PostTasksIdMergeRequest"
15246
+ }
15247
+ }
15248
+ },
15249
+ "x-validated": true
15250
+ },
15251
+ "responses": {
15252
+ "200": {
15253
+ "description": "Success."
15254
+ },
15255
+ "400": {
15256
+ "$ref": "#/components/responses/ValidationFailed"
15257
+ },
15258
+ "401": {
15259
+ "$ref": "#/components/responses/Unauthorized"
15260
+ },
15261
+ "403": {
15262
+ "$ref": "#/components/responses/Forbidden"
15263
+ },
15264
+ "404": {
15265
+ "$ref": "#/components/responses/NotFound"
15266
+ }
15267
+ },
15268
+ "security": [
15269
+ {
15270
+ "builderSession": []
15271
+ }
15272
+ ]
15273
+ }
15274
+ },
15216
15275
  "/tasks/{id}/override-request": {
15217
15276
  "post": {
15218
15277
  "operationId": "post_tasks_id_override_request",
@@ -21790,6 +21849,22 @@
21790
21849
  },
21791
21850
  "additionalProperties": false
21792
21851
  },
21852
+ "PostTasksIdMergeRequest": {
21853
+ "type": "object",
21854
+ "properties": {
21855
+ "into_task_id": {
21856
+ "type": "integer",
21857
+ "minimum": 1
21858
+ },
21859
+ "reason": {
21860
+ "type": "string"
21861
+ }
21862
+ },
21863
+ "required": [
21864
+ "into_task_id"
21865
+ ],
21866
+ "additionalProperties": false
21867
+ },
21793
21868
  "PostTasksIdOverrideRequestRequest": {
21794
21869
  "type": "object",
21795
21870
  "properties": {
@@ -22384,8 +22459,8 @@
22384
22459
  "description": "A required dependency/feature is not configured or is temporarily down."
22385
22460
  }
22386
22461
  },
22387
- "x-endpoint-count": 355,
22388
- "x-schema-count": 390,
22462
+ "x-endpoint-count": 356,
22463
+ "x-schema-count": 391,
22389
22464
  "x-undocumented-bodies": 9,
22390
22465
  "x-response-schemas": 265,
22391
22466
  "x-generated-by": "scripts/gds/gen-api-docs.js"
@@ -2,7 +2,7 @@
2
2
 
3
3
  # Bongos API reference
4
4
 
5
- > **Generated from the live route files** — the route file is authoritative. 355 endpoints across 58 route files.
5
+ > **Generated from the live route files** — the route file is authoritative. 356 endpoints across 58 route files.
6
6
  > Machine-readable spec: [`docs/api/openapi.json`](api/openapi.json) (OpenAPI 3.1). Rendered docs site: **`/docs`** (e.g. `cloudbongos.com/docs`).
7
7
 
8
8
  Base path: `/api/bongos`. Ranks (enforced server-side, [ADR 0016](adr/<redacted>.md)): `public` < `any-builder` < `metic+archon` < `archon`.
@@ -581,7 +581,7 @@ Base path: `/api/bongos`. Ranks (enforced server-side, [ADR 0016](adr/<redacted>
581
581
  |---|---|---|---|---|
582
582
  | GET | `/api/bongos/task-visuals/:name` | `public` | — | the WIDEST path this route can take, declared honestly. |
583
583
 
584
- ## `tasks` (31)
584
+ ## `tasks` (32)
585
585
 
586
586
  | Method | Path | Rank | Body | Description |
587
587
  |---|---|---|---|---|
@@ -601,6 +601,7 @@ Base path: `/api/bongos`. Ranks (enforced server-side, [ADR 0016](adr/<redacted>
601
601
  | PUT | `/api/bongos/tasks/:id/dependencies` | `metic+archon` | `depends_on_task_ids` | PUT /tasks/:id/dependencies — replace the full dep set. |
602
602
  | DELETE | `/api/bongos/tasks/:id/dependencies/:depId` | `metic+archon` | — | DELETE /tasks/:id/dependencies/:depId — removes the edge. |
603
603
  | POST | `/api/bongos/tasks/:id/grade` | `any-builder` | `subagent_output`, `signals`, `committed_files`, `diff_hash` | GDS-V3 task V3.R18 (task 209). |
604
+ | POST | `/api/bongos/tasks/:id/merge` | `metic+archon` | `into_task_id`, `reason` | POST /tasks/:id/merge — mark a duplicate task merged into its canonical one (task 1001478 — the /bug-triage counterpart to idea_inbox's m… |
604
605
  | POST | `/api/bongos/tasks/:id/override-request` | `any-builder` | `rationale` | POST /tasks/:id/override-request — V3.R74 (task 293). |
605
606
  | POST | `/api/bongos/tasks/:id/promote` | `metic+archon` | — | backlog→ready promotion; scope mutation; ADR 0090 (was archon). |
606
607
  | POST | `/api/bongos/tasks/:id/publish-branch` | `any-builder` | `branch`, `head_sha`, `bundle_b64`, `value_summary` | |
package/docs/file-map.md CHANGED
@@ -375,6 +375,7 @@ tests/
375
375
  .claude/skills/
376
376
  ├── blocker-review/SKILL.md ← /blocker-review — daily review of open GDS blockers (resolve / escalate / note); Metic+
377
377
  ├── blocker-solve/SKILL.md ← /blocker-solve N — drive ONE blocker to done: do the doable parts, hand back owner-only steps, verify, auto-resolve (auto-promotes waiters); Metic+
378
+ ├── bug-triage/SKILL.md ← /bug-triage — daily walk through open kind=bug tasks (batch-cluster near-dups, merge / won't-fix); the idea-triage counterpart built on the new tasks.merge primitive (task 1001478); Metic+
378
379
  ├── builder-backup/SKILL.md ← /builder-backup — check DB backup status or trigger a fresh local dump before risky changes (migrations, destructive SQL); no SSH required
379
380
  ├── builder-box/SKILL.md ← /builder-box — show the builder their own dev box (state, IP/host, cost, SSH keys) and help them (re)connect
380
381
  ├── builder-claim/SKILL.md ← /builder-claim N — atomic claim; prints the claim-time context pack + discipline playbook routing
@@ -1549,5 +1549,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
1549
1549
  landed since 1.19.548 with no explicit bump. run 33906859934. (task 1002620)
1550
1550
  1.19.550 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1551
1551
  landed since 1.19.549 with no explicit bump. run 33907331050. (task 1002620)
1552
+ 1.19.551 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1553
+ landed since 1.19.550 with no explicit bump. run 33917261720. (task 1002620)
1554
+ 1.19.552 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1555
+ landed since 1.19.551 with no explicit bump. run 34043771286. (task 1002620)
1552
1556
  ---------------------------------------------------------------------------
1553
1557
  ```
@@ -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;
@@ -14,6 +14,7 @@ const api = require('../../src/module-api');
14
14
  const { pool } = api;
15
15
  const doneWhen = require('./done-when.js');
16
16
  const { deriveRequiredRank, highestRank } = require('./db-rank-authz.js');
17
+ const { nonTerminalSql } = require('./task-status');
17
18
 
18
19
  // -------------------------------------------------------------------------
19
20
  // Goals (ADR 0086 §2 / BV1.R56 — task 1510)
@@ -328,7 +329,7 @@ async function listCriteriaForGoal(goalId, deps = {}) {
328
329
  -- task 1002651 (migration 017's family): abandoned is a terminal
329
330
  -- state (deliberately cut), not pending work — it must not hold a
330
331
  -- criterion's "met — pending review" flip hostage forever.
331
- count(*) FILTER (WHERE t.status NOT IN ('shipped', 'abandoned'))::int AS unshipped_tasks
332
+ count(*) FILTER (WHERE ${nonTerminalSql()})::int AS unshipped_tasks
332
333
  FROM task_criteria tc
333
334
  JOIN tasks t ON t.id = tc.task_id
334
335
  WHERE tc.criterion_id IN (SELECT id FROM crit)
@@ -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
@@ -24,6 +24,7 @@
24
24
  // POST /api/gds/done-when/:criterionId/unsatisfy — auth
25
25
 
26
26
  const { pool } = require('../../src/module-api');
27
+ const { nonTerminalSql } = require('./task-status');
27
28
 
28
29
  // isPendingReview — the ONE derivation of the "met — pending review" auto-flag
29
30
  // (ADR 0086 §6 / BV1.R63): a criterion with >=1 linked task, every one shipped,
@@ -134,7 +135,7 @@ async function listPendingReviewCriteria({ versionId = null } = {}, deps = {}) {
134
135
  JOIN tasks t ON t.id = tc.task_id
135
136
  WHERE c.satisfied = false
136
137
  GROUP BY c.id
137
- HAVING count(*) FILTER (WHERE t.status NOT IN ('shipped', 'abandoned')) = 0
138
+ HAVING count(*) FILTER (WHERE ${nonTerminalSql()}) = 0
138
139
  ),
139
140
  goal_unsat AS (
140
141
  SELECT goal_id, count(*)::int AS unsatisfied
@@ -426,7 +427,7 @@ async function autoSatisfyShippedCriteria(exec, { taskId = null } = {}) {
426
427
  AND ($1::bigint IS NULL OR c.id IN (
427
428
  SELECT criterion_id FROM task_criteria WHERE task_id = $1::bigint))
428
429
  GROUP BY c.id
429
- HAVING count(*) FILTER (WHERE t.status NOT IN ('shipped', 'abandoned')) = 0
430
+ HAVING count(*) FILTER (WHERE ${nonTerminalSql()}) = 0
430
431
  AND count(*) FILTER (WHERE t.status = 'shipped') >= 1
431
432
  )
432
433
  UPDATE done_when_criteria c
@@ -32,10 +32,12 @@
32
32
  const api = require('../../src/module-api');
33
33
  const { pool } = api;
34
34
 
35
- // The status vocabularies, kept beside the SQL that uses them so a reader can
36
- // check them against goals-lib.js in one glance.
37
- const DONE_STATUSES = ['shipped', 'confirmed', 'completed'];
38
- const CUT_STATUSES = ['abandoned', 'dropped'];
35
+ // The status vocabularies live in task-status.js (task 1003588) so this module
36
+ // has ONE spelling of each. These two are the PRESENTATIONAL buckets — they
37
+ // mirror goals-lib.js `readinessOf`, and they are NOT the terminal set: `done`
38
+ // here includes the mid-ship states on purpose, which is right for a progress
39
+ // ring and wrong for any question about whether a goal may close.
40
+ const { DONE_STATUSES, CUT_STATUSES } = require('./task-status');
39
41
 
40
42
  // goalRollups — one row per goal that has at least one task in `versionId`.
41
43
  // Returns [{ goal_id, done, active, ready, blocked, backlog, cut, total,
@@ -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
@@ -0,0 +1,72 @@
1
+ 'use strict';
2
+
3
+ // modules/lifecycle/task-status.js — the task-status vocabulary, declared once
4
+ // (task 1003588, goal 1000086, criterion sv-goal-outlives-nothing).
5
+ //
6
+ // WHY THIS FILE EXISTS. "Finished" had two spellings inside one module.
7
+ // `goal-rollup.js` counted shipped|confirmed|completed as done; `done-when.js`
8
+ // counted only shipped and treated abandoned as accounted-for; `db-goals.js`,
9
+ // `version-close.js` and the `sequence` CLI each re-typed the SQL literal
10
+ // `NOT IN ('shipped', 'abandoned')` by hand. Two definitions of finished in one
11
+ // module is precisely what lets a goal read closed on one surface and open on
12
+ // another — the failure goal 1000086 exists to make impossible. So the
13
+ // vocabulary is declared here, once, and every consumer reads it.
14
+ //
15
+ // THE TWO SETS ARE NOT THE SAME QUESTION, and conflating them is the bug in the
16
+ // other direction:
17
+ //
18
+ // TERMINAL_STATUSES — the LEDGER truth. A task is finished with the project
19
+ // when it shipped or was deliberately abandoned. Nothing else is terminal:
20
+ // `completed` and `confirmed` are mid-ship states that can still fail a
21
+ // grade or strand, so work sitting in them is unfinished work and must hold
22
+ // its goal open. This is the set the closure invariants read.
23
+ //
24
+ // DONE_STATUSES / CUT_STATUSES — a PRESENTATIONAL bucketing for the goals
25
+ // board, mirroring the client's `goals-lib.js readinessOf` verbatim (a
26
+ // divergence shows a person different numbers per surface, which
27
+ // tests/goal_sort_staleness.mjs pins). "Done" there means "no longer in the
28
+ // queue", which deliberately includes the mid-ship states. It is a progress
29
+ // ring, not a ledger, and must never be used to decide whether a goal may
30
+ // close.
31
+ //
32
+ // NO DEPENDENCIES, deliberately. This file requires nothing — not even
33
+ // `module-api` — so a CLI (`scripts/gds/sequence.js`) can read the vocabulary
34
+ // without dragging the server's `db.js` and its connection pool into a process
35
+ // that only needs a Set of two strings.
36
+
37
+ // TERMINAL_STATUSES — the only spelling of "this task is finished with the
38
+ // project". shipped = delivered; abandoned = deliberately cut (task 1002651,
39
+ // migration 017's family). Everything else holds work open.
40
+ const TERMINAL_STATUSES = ['shipped', 'abandoned'];
41
+
42
+ // TERMINAL_STATUS_SET — the same set for JS membership tests.
43
+ const TERMINAL_STATUS_SET = new Set(TERMINAL_STATUSES);
44
+
45
+ // isTerminalStatus — true when a task in this status is finished with the
46
+ // project and can no longer hold a criterion, a goal, or a version open.
47
+ function isTerminalStatus(status) {
48
+ return TERMINAL_STATUS_SET.has(status);
49
+ }
50
+
51
+ // nonTerminalSql — the SQL predicate for "this task is still unfinished",
52
+ // rendered from TERMINAL_STATUSES so the literal exists in exactly one place.
53
+ // `column` is the status expression in the caller's query (`t.status` under a
54
+ // join, bare `status` in a single-table read). Output is the byte-identical
55
+ // `<column> NOT IN ('shipped', 'abandoned')` every call site used to hardcode.
56
+ function nonTerminalSql(column = 't.status') {
57
+ return `${column} NOT IN (${TERMINAL_STATUSES.map((s) => `'${s}'`).join(', ')})`;
58
+ }
59
+
60
+ // DONE_STATUSES / CUT_STATUSES — the goals-board buckets. Presentational only;
61
+ // see the header. Mirrors goals-lib.js `readinessOf`.
62
+ const DONE_STATUSES = ['shipped', 'confirmed', 'completed'];
63
+ const CUT_STATUSES = ['abandoned', 'dropped'];
64
+
65
+ module.exports = {
66
+ TERMINAL_STATUSES,
67
+ TERMINAL_STATUS_SET,
68
+ isTerminalStatus,
69
+ nonTerminalSql,
70
+ DONE_STATUSES,
71
+ CUT_STATUSES,
72
+ };