@bongos/core 1.19.332 → 1.19.333

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 (58) hide show
  1. package/.bongos-core.json +160 -40
  2. package/docs/api/openapi.json +2 -2
  3. package/docs/api-reference.md +1 -1
  4. package/docs/copy-inventory.md +202 -202
  5. package/docs/copy-registry.json +384 -384
  6. package/modules/grading/grader-config.js +98 -0
  7. package/modules/grading/grader-model.js +150 -0
  8. package/modules/grading/grader-prompt.js +139 -0
  9. package/modules/grading/grader-score.js +706 -0
  10. package/modules/grading/grader-subagent.js +328 -0
  11. package/modules/grading/grader.js +5 -1287
  12. package/modules/hall-ui/public/builders.js +19 -1182
  13. package/modules/hall-ui/public/goals-page.js +11 -997
  14. package/modules/hall-ui/public/goals-render.js +1032 -0
  15. package/modules/hall-ui/public/goals.html +4 -0
  16. package/modules/hall-ui/public/hall-render.js +1211 -0
  17. package/modules/hall-ui/public/index.html +6 -0
  18. package/modules/lifecycle/routes/goal-route-authz.js +400 -0
  19. package/modules/lifecycle/routes/goals.js +1 -367
  20. package/modules/lifecycle/routes/task-route-helpers.js +219 -0
  21. package/modules/lifecycle/routes/task-write-routes.js +542 -0
  22. package/modules/lifecycle/routes/tasks.js +3 -696
  23. package/package-lock.json +2 -2
  24. package/package.json +1 -1
  25. package/scripts/gds/box-infra.js +280 -0
  26. package/scripts/gds/box.js +1 -245
  27. package/scripts/gds/fitness-checks-identity.js +320 -0
  28. package/scripts/gds/fitness-checks-vocabulary.js +230 -0
  29. package/scripts/gds/fitness-checks-write-validation.js +144 -0
  30. package/scripts/gds/fitness-lib.js +82 -0
  31. package/scripts/gds/fitness.js +55 -668
  32. package/scripts/gds/provision-config.js +204 -0
  33. package/scripts/gds/provision-net.js +221 -0
  34. package/scripts/gds/provision-repo.js +849 -0
  35. package/scripts/gds/provision-units.js +262 -0
  36. package/scripts/gds/provision.js +4 -1395
  37. package/src/bongos/auth-admission.js +264 -0
  38. package/src/bongos/auth-config.js +151 -0
  39. package/src/bongos/auth-discord.js +198 -0
  40. package/src/bongos/auth-github.js +336 -0
  41. package/src/bongos/auth-idp.js +330 -0
  42. package/src/bongos/auth.js +5 -1100
  43. package/src/module-api.js +3 -1
  44. package/tests/auth_admission_gate.mjs +3 -1
  45. package/tests/est_advisory.mjs +3 -1
  46. package/tests/goal_advisory.mjs +3 -2
  47. package/tests/goal_criterion_confirm_ui.mjs +6 -0
  48. package/tests/goal_subtitle.mjs +5 -2
  49. package/tests/goals_card_view.mjs +10 -3
  50. package/tests/grader_generated_provenance.mjs +3 -1
  51. package/tests/hall_palette.mjs +3 -1
  52. package/tests/helpers.mjs +58 -25
  53. package/tests/lib_sh_resolution.mjs +3 -1
  54. package/tests/membership_kind_lockstep.mjs +3 -1
  55. package/tests/migration_allocation.mjs +5 -2
  56. package/tests/patch_gate_ordering.mjs +3 -1
  57. package/tests/rank_tier_single_source.mjs +16 -3
  58. package/tests/reward_gate.mjs +3 -2
@@ -41,191 +41,8 @@ const PUBLISH_BUNDLE_MAX_BYTES = 20 * 1024 * 1024; // 20 MB decoded bundle
41
41
  // autolinks to GitHub's own issue space (dead links for GDS task ids — task 916).
42
42
  // Mirrors ship.js sanitizeGitHubRefs (which lives in scripts/ and isn't requirable
43
43
  // here).
44
- function deHashRefs(text) {
45
- return String(text == null ? '' : text).replace(/#(\d{1,5})\b/g, 'task $1');
46
- }
47
-
48
- // tasks.discipline — orthogonal to kind. See migrations 014 + 086.
49
- // VALID_DISCIPLINES (engineer = builds code · artist = makes visual assets ·
50
- // ideator = thinks/writes/scopes · unclassified = un-triaged) is imported above
51
- // from ../task-classifier — the single source (D1).
52
-
53
- // ?fit= on /tasks/claimable — narrows to tasks the autonomous overnight
54
- // session-runner (#358) may pick up. See db.listClaimableTasks.
55
- const VALID_FITS = new Set(['autonomous', 'parallel']);
56
-
57
- // V3.R48 (#267): auto-detect schema-change tasks so the touches[] scanner
58
- // reserves a migration number even when the builder forgets to list
59
- // 'migrations/'. The keyword set is intentionally conservative — it matches
60
- // whole tokens ("migration"/"migrations", "schema") and the two SQL phrases
61
- // that almost always imply a migration, so unrelated words ("schematic",
62
- // "reschema", a button color) don't false-positive. Case-insensitive.
63
- //
64
- // Migration-keyword detection. POST /tasks uses this to set the
65
- // tasks.needs_migration boolean (migration 081, touches[] cleanup [1/8] /
66
- // task 876), which is what the claim-time allocator now keys off — replacing
67
- // the old "inject 'migrations/' into touches[]" sentinel. Conservative,
68
- // whole-token, case-insensitive. The former scripts/gds/capture.js mirror was
69
- // deleted with the same task: captured ideas have no touches[] and the server
70
- // re-detects on create, so the CLI copy earned its keep no longer.
71
- const MIGRATION_KEYWORD_RE =
72
- /\bmigrations?\b|\bschema\b|\balter\s+table\b|\bcreate\s+table\b/i;
73
-
74
- // task 1002648 (idea 1000642): compounds where the keyword is qualified by a
75
- // NON-database noun are refactor/API language, not schema work, and are blanked
76
- // before the keyword test. Two families surfaced in the wild: "gate/route/code
77
- // migration" (code-migration phrasing) and "request/PATCH/:NNN schema"
78
- // (API-shape phrasing — the task-1002614 false positive, which reserved
79
- // migration 213 for zero schema change). Bare "migration"/"schema" and every
80
- // DB-shaped compound ("schema migration", "DB schema") still flag.
81
- const NON_DB_COMPOUND_RE = new RegExp(
82
- [
83
- /\b(?:gate|route|code)s?[-\s]+migrations?\b/.source,
84
- /\bmigrations?\s+of\s+(?:gate|route|code)s?\b/.source,
85
- /\b(?:request|response|body|validation|patch|post|put|delete|json|openapi|api|error)[-\s]+schemas?\b/.source,
86
- /:\d+[-\s]+schemas?\b/.source,
87
- ].join('|'),
88
- 'gi'
89
- );
90
-
91
- function detectsMigration(...texts) {
92
- return texts.some(
93
- (t) => typeof t === 'string' && MIGRATION_KEYWORD_RE.test(t.replace(NON_DB_COMPOUND_RE, ' '))
94
- );
95
- }
96
-
97
- // ---- peer votes (V3.R25 #245) ---------------------------------------------
98
- // The peer-vote SQL lives here (inline, via the route module's `pool`) rather
99
- // than in db.js on purpose: it's a self-contained feature, declared in this
100
- // task's touches[] (migrations/, this file, modules/hall-ui/), and keeping it
101
- // out of the large shared db.js avoids a parallel-safety conflict on that file.
102
- // Same deliberate-locality call as the MIGRATION_KEYWORD_RE mirror above.
103
-
104
- // VOTABLE_TASK_STATUSES — a task is votable once it's verified work the project
105
- // keeps (confirmed = past the grader, not yet paid; or shipped = deployed + paid);
106
- // backlog/ready/completed are not. Imported above from ../task-classifier (D1).
107
-
108
- // #853 (ADR 0041): temporary, default-OFF global grader bypass. When the env
109
- // GRADER_BYPASS_ENABLED is exactly '1', the subagent quality grader is treated
110
- // as unavailable and any builder may land work via /tasks/:id/confirm (normally
111
- // archon-only). Read live on each request — flipping the env (+ redeploy) is the
112
- // on/off switch; no caching, so turning it off re-arms the trust boundary at
113
- // once. Exported so /me can surface the same flag to ship.js.
114
- function graderBypassEnabled() {
115
- return process.env.GRADER_BYPASS_ENABLED === '1';
116
- }
117
-
118
- // SECURITY (#871): claim-ownership gate for the lifecycle-advancing routes
119
- // (/grade, /confirm, /ship). These are requireBuilder-only (the grade is meant
120
- // to be an objective subagent output; the supervisor hot path must not need an
121
- // Archon), which — combined with applyGrade/confirmTask crediting work — let any
122
- // authenticated builder act on ANOTHER builder's task:
123
- // * /grade (requireBuilder, no rank gate): a builder could POST a fabricated
124
- // passing grade on someone else's `completed` task. Pre-#871 applyGrade then
125
- // credited the CALLER → cross-builder credit theft + ship-attribution hijack
126
- // + a full bypass of the quality grader.
127
- // * /confirm: archon-only normally, but the GRADER_BYPASS killswitch (ON
128
- // during the grader outage) relaxes it to ANY builder, so anyone could
129
- // force-confirm anyone's `completed` work past the (absent) grader.
130
- // * /ship: any-builder supervisor hot path (so /merge-mode can land the queue).
131
- //
132
- // Allow when: the caller is an Archon (full authority / --regrade); OR — for
133
- // /ship only (allowSupervisor) — the caller is Metic, the trusted rank that runs
134
- // /merge-mode to sweep the confirmed queue across builders; OR the caller is the
135
- // task's most-recent claim holder (the builder who did the work).
136
- //
137
- // A NEVER-CLAIMED task (degenerate state, e.g. status set directly via SQL) is
138
- // NOT an open door: the Archon and supervisor early-returns above already cover
139
- // the legitimate actors who land such a task. By the time we read the claim
140
- // holder, the caller is necessarily a non-Archon (and not a supervisor Metic) —
141
- // so a missing claim must FAIL CLOSED with the same 403, not silently ALLOW.
142
- // (L2 residual, red-team 2026-06-12: combined with the GRADER_BYPASS relaxation
143
- // on /confirm, the old "no claim → ALLOW" let any builder confirm a claimless
144
- // `completed` task and have the credit fall to themselves.) An Archon (or the
145
- // /merge-mode supervisor) can still land a claimless task — they pass above.
146
- // Returns true if allowed; otherwise writes a 403 and returns false.
147
- async function gateTaskOwnership(req, res, taskId, { allowSupervisor = false } = {}) {
148
- const rank = req.builder && req.builder.rank;
149
- if (rank === 'archon') return true;
150
- if (allowSupervisor && rank === 'metic') return true;
151
- const holder = await db.getLastClaimHolder(taskId);
152
- if (holder && String(holder.builder_id) === String(req.builder.id)) return true;
153
- // task 1002614 (map N-2): a mistyped id used to earn the same not_your_task
154
- // 403 as someone else's work — a misattribution that sent builders debugging
155
- // ownership. An ABSENT task is the clean 404 an Archon already saw (they pass
156
- // above and hit the handler's own lookup). A never-claimed task that EXISTS
157
- // still fails closed with the 403 below (the L2 credit-theft block).
158
- if (!holder && !(await db.getTask(taskId))) {
159
- res.fail('task_not_found', 404);
160
- return false;
161
- }
162
- res.fail('not_your_task', { status: 403, message: 'Only the task\'s claim holder (or an Archon) may grade/confirm/ship it.' });
163
- return false;
164
- }
165
-
166
- // task 1053 (F): a verification task — one whose deliverable IS a verification
167
- // (e.g. a hardware-verify run). Confirming one requires an explicit attestation
168
- // that the verify actually passed, so a hardware-verify can't be confirmed on a
169
- // passing code-grade before the verify was even performed (the #1027 trap).
170
- // Keyed on the explicit kind (set by the planner), NOT a title regex — a task
171
- // ABOUT verify work would false-positive on text. Pure + exported for tests.
172
- function isVerifyTask(task) {
173
- return !!task && task.kind === 'verify';
174
- }
175
-
176
- // task 1139 / 1169 (ADR 0058): the trustless server rank-attestation behind the
177
- // gate-review CI check. Only the SERVER can set it — it holds the authenticated
178
- // builder's server-resolved rank AND the App/PAT credential — so CI can trust it
179
- // where a PR-author-controlled signal (a PR title) would be spoofable. A Metic+
180
- // author's gate-author-trust=success clears a NEEDS_TRUST gate surface with no
181
- // owner click; below Metic gets no attestation (the gate escalates → owner review).
182
- //
183
- // rankAttestsGateTrust is the PURE rank decision (no network) — exported for tests.
184
- function rankAttestsGateTrust(rank) {
185
- const { tierOf, MIN_TIER } = api.permissionPathCheck;
186
- const tier = tierOf(rank);
187
- return tier != null && tier >= MIN_TIER;
188
- }
189
-
190
- // postGateAuthorTrust — set (or skip) the gate-author-trust commit status for an
191
- // author's rank on a pushed SHA. Shared by the server-mediated publish path
192
- // (POST /tasks/:id/publish-branch) and the laptop ci-land path's attest endpoint
193
- // (POST /tasks/:id/attest-gate). Best-effort + never throws: a failed/skipped
194
- // status post leaves the PR un-attested, which ESCALATES (the safe direction — no
195
- // rank bypass without a real server signal). Returns { attested, reason } for UX.
196
- async function postGateAuthorTrust({ rank, sha, taskId }) {
197
- if (!rankAttestsGateTrust(rank)) return { attested: false, reason: 'rank_below_metic' };
198
- try {
199
- const ok = await githubPush.setCommitStatus({
200
- sha,
201
- state: 'success',
202
- context: 'gate-author-trust',
203
- description: `Metic+ author (${rank}) — task ${taskId}`,
204
- });
205
- return { attested: !!ok, reason: ok ? null : 'status_post_failed' };
206
- } catch (_) {
207
- return { attested: false, reason: 'status_post_failed' };
208
- }
209
- }
210
-
211
- async function peerVoteNet(taskId) {
212
- const { rows } = await pool.query(
213
- `SELECT COALESCE(SUM(vote), 0)::int AS net, COUNT(*)::int AS total
214
- FROM peer_votes WHERE target_task_id = $1`,
215
- [taskId]
216
- );
217
- return { net: rows[0].net, total: rows[0].total };
218
- }
219
-
220
- // #514: bad_kind responses carried a `valid` array but no human-readable line,
221
- // so a caller (or CLI that only prints .message) couldn't tell at a glance what
222
- // went wrong — the #506 session burned a turn guessing kind=chore. Add a
223
- // .message that spells out the allowed values while keeping the valid[] array.
224
- function badKind(res) {
225
- const valid = Array.from(VALID_KINDS);
226
- return res.fail('bad_kind', { status: 400, message: `kind must be one of: ${valid.join(', ')}`, details: { valid } });
227
- }
228
-
44
+ const { MIGRATION_KEYWORD_RE, VALID_FITS, badKind, deHashRefs, detectsMigration, gateTaskOwnership, graderBypassEnabled, isVerifyTask, peerVoteNet, postGateAuthorTrust, rankAttestsGateTrust } = require('./task-route-helpers.js');
45
+ const registerTaskWriteRoutes = require('./task-write-routes.js'); // task 1003206 — POST /tasks + PATCH /tasks/:id
229
46
  module.exports = function buildTasksRouter() {
230
47
  const router = express.Router();
231
48
 
@@ -549,517 +366,7 @@ module.exports = function buildTasksRouter() {
549
366
  res.json({ ok: true, removed });
550
367
  });
551
368
 
552
- // Task create/promote/edit (plan/scope mutation) is Metic+ as of ADR 0090
553
- // (owner decision 2026-06-26): a Metic who can run idea-triage may also seed +
554
- // shape tasks. Was Archon-only (task #106 / ADR 0016; three-rank model #360 /
555
- // ADR 0018). A Xenos/Thetes still cannot author — they file ideas. Setting rank
556
- // and closing versions stay Archon. The every-route audit is task #231 (V3.R06).
557
- // rank: metic+archon — create work (plan/scope mutation); ADR 0090 (was ADR 0016/0018 archon).
558
- router.post('/tasks', auth.requireBuilder, auth.requirePermission('task.create'), async (req, res) => {
559
- if (validateOrRespond(req, res, {
560
- version_id: { required: true, type: 'string', maxLength: 64 },
561
- title: { required: true, type: 'string', maxLength: LIMITS.TITLE, minLength: 1 },
562
- description: { type: 'string', maxLength: LIMITS.DESCRIPTION },
563
- status: { type: 'string', maxLength: 32 },
564
- touches: { type: 'array', maxItems: 500, itemsType: 'string' },
565
- est_minutes: { type: 'integer', min: 0, max: 100000 }, // AGENT-minutes — see est-advisory.js (task 1003148)
566
- est_cost_usd: { type: 'number', min: 0 },
567
- manual_degree: { type: 'integer', min: TASK_FIELD_BOUNDS.manual_degree.min, max: TASK_FIELD_BOUNDS.manual_degree.max },
568
- priority: { type: 'integer', min: TASK_FIELD_BOUNDS.priority.min, max: TASK_FIELD_BOUNDS.priority.max },
569
- automation_tag: { type: 'string', maxLength: 64 },
570
- credits_reward: { type: 'integer', min: 0, max: 1000000 },
571
- source: { type: 'string', maxLength: LIMITS.SOURCE },
572
- source_ref: { type: 'string', maxLength: LIMITS.SOURCE_REF },
573
- value_summary: { type: 'string', maxLength: LIMITS.VALUE_SUMMARY },
574
- kind: { type: 'string', maxLength: 64 },
575
- discipline: { type: 'string', maxLength: 64 },
576
- parallel_safe: {}, // accepts true/false/null — type checked below
577
- newcomer_friendly: { type: 'boolean' }, // V3.R15 #239 — Xenos-claimable flag
578
- security_sensitive: { type: 'boolean' }, // task 990 — Archon-only ship broadcast
579
- requires_rank: { type: 'string', maxLength: 16 }, // task 1498 / ADR 0084 — per-task rank floor (auto-derived; explicit value can only raise it)
580
- auto_detect_touches: { type: 'boolean' },
581
- // task 438: link the new task to done-when criteria at creation time. Each
582
- // element is a numeric done_when_criteria.id, a positional "Cn" token, or a
583
- // criterion_id slug — resolved against this task's version below (ADR 0025).
584
- criterion_ids: { type: 'array', maxItems: 100 },
585
- // BV1.R53 (task 1422): module affinity label. Drives module-disjointness at
586
- // claim time and diff⊆module drift detection at ship time.
587
- module_key: { type: 'string', maxLength: 64 },
588
- // Parent the new task under a specific goal at create (task 1763 added the
589
- // PATCH-move vector; this is the create-time counterpart so a planning seed
590
- // lands tasks under their goal, not the version's default catch-all).
591
- // Validated to exist + belong to this version below.
592
- goal_id: { type: 'integer', min: 1 },
593
- })) return;
594
- const body = req.body || {};
595
- if (!body.version_id || !body.title) {
596
- return res.fail('version_id_and_title_required', 400);
597
- }
598
- // task 975: a version is the ledger of one bounded chunk of work. Once it
599
- // ships it is sealed history — its progress bar reads 100% and its scope
600
- // is archived. Filing new work against it would reopen a closed book and
601
- // (until the view fix above) silently drag the bar back below 100%. Reject
602
- // creation against a missing or shipped version; the caller should target
603
- // an open version or file the idea to idea_inbox for the next planning
604
- // session. (POST /tasks is the only API create-vector — PATCH cannot
605
- // re-bind version_id — so this is the complete guard.)
606
- const targetVersion = await db.getVersion(body.version_id);
607
- if (!targetVersion) {
608
- return res.fail('version_not_found', { status: 404, message: `No version '${body.version_id}'. Create the task under an existing, open version.` });
609
- }
610
- // task 386 (idea #85): idempotency on (source, source_ref). A re-submitted
611
- // create — a retried POST, a capture/promotion re-run — must resolve to the
612
- // ORIGINAL task, not silently mint a duplicate (the #248-dup-of-#252 class of
613
- // bug). Runs BEFORE the version-closed guard so re-POSTing the same ref after
614
- // its task already shipped (version now closed) returns the existing row
615
- // instead of a spurious 409. `source` defaults to 'manual' to match the
616
- // createTask call below, so the lookup key is exactly what would be written.
617
- const effectiveSource = body.source ?? 'manual';
618
- if (body.source_ref) {
619
- const existing = await db.findTaskBySourceRef({ source: effectiveSource, sourceRef: body.source_ref });
620
- if (existing) {
621
- const criteria = await db.getTaskCriteria(existing.id);
622
- return res.status(200).json({
623
- task: existing,
624
- needs_migration: existing.needs_migration === true,
625
- criteria,
626
- idempotent: true,
627
- });
628
- }
629
- }
630
- if (targetVersion.status === 'shipped') {
631
- return res.fail('version_closed', { status: 409, message: `Version '${body.version_id}' has shipped — it is closed history and cannot take new tasks. ` +
632
- `Target an open version, or capture this in idea_inbox for the next planning session.` });
633
- }
634
- // task 438: resolve any criterion_ids BEFORE creating the task, so a bad ref
635
- // is a clean 400 with nothing created. Scoped to this task's version, so "C8"
636
- // is unambiguous and a numeric id from another version doesn't resolve.
637
- let criterionIdsResolved = [];
638
- if (Array.isArray(body.criterion_ids) && body.criterion_ids.length > 0) {
639
- const { ids, unresolved } = await db.resolveCriterionIds(body.version_id, body.criterion_ids);
640
- if (unresolved.length > 0) {
641
- return res.fail('bad_criterion_ref', { status: 400, message: `These criterion refs don't match any criterion of version '${body.version_id}': ` +
642
- `${unresolved.join(', ')}. Use a numeric done_when_criteria.id, a "Cn" position, or a criterion_id slug.`, details: { unresolved } });
643
- }
644
- criterionIdsResolved = ids;
645
- }
646
- // Resolve kind: caller wins if they specified a valid non-unclassified
647
- // value; otherwise run the heuristic classifier on title+description.
648
- // Confidence < 0.5 falls through to 'unclassified' for triage.
649
- let kind = body.kind;
650
- if (kind && !VALID_KINDS.has(kind)) {
651
- return badKind(res);
652
- }
653
- if (!kind || kind === 'unclassified') {
654
- const result = classifyTaskKind(body.title, body.description || '');
655
- kind = result.confidence >= 0.5 ? result.kind : 'unclassified';
656
- }
657
- const discipline = body.discipline ?? 'unclassified';
658
- if (!VALID_DISCIPLINES.has(discipline)) {
659
- return res.fail('bad_discipline', 400, { valid: Array.from(VALID_DISCIPLINES) });
660
- }
661
- // task 1498 / ADR 0084: an explicit requires_rank must be a live rank. The
662
- // floor auto-derives from touches + security_sensitive; createTask clamps
663
- // any explicit value UP to that floor (never below), so this only rejects a
664
- // malformed enum.
665
- if (body.requires_rank != null) {
666
- const LIVE_RANKS = new Set(db.LIVE_RANK_LADDER);
667
- if (!LIVE_RANKS.has(body.requires_rank)) {
668
- return res.fail('bad_requires_rank', { status: 400, message: `requires_rank must be one of ${db.LIVE_RANK_LADDER.join(' | ')}. The floor auto-derives from touches + security_sensitive; an explicit value can only raise it.`, details: { valid: db.LIVE_RANK_LADDER } });
669
- }
670
- }
671
- // BV1.R53 (task 1422): validate module_key against MODULE_SCOPE_MAP.
672
- if (body.module_key != null && !isModuleKey(body.module_key)) {
673
- return res.fail('bad_module_key', { status: 400, message: `module_key must be one of the known module keys, or omitted. Valid keys: ${moduleKeys().join(', ')}.`, details: { valid: moduleKeys() } });
674
- }
675
- // V3.R48 (#267) → migration 081 (task 876): if the title/description reads
676
- // like a schema change, flag tasks.needs_migration so the claim-time
677
- // allocator reserves a migration number. This used to inject the literal
678
- // 'migrations/' into touches[]; it now sets a dedicated boolean and leaves
679
- // touches[] alone. Opt out with { auto_detect_touches: false }.
680
- const touches = Array.isArray(body.touches) ? [...body.touches] : [];
681
- const autoDetect = body.auto_detect_touches !== false;
682
- const needsMigration = autoDetect && detectsMigration(body.title, body.description);
683
- // Validate an explicit goal_id: it must exist AND belong to THIS version (a task
684
- // can't be parented under another version's goal). Absent → the default-goal
685
- // trigger files it under the version's catch-all goal, exactly as before.
686
- if (body.goal_id != null) {
687
- const goal = await db.getGoal(body.goal_id);
688
- if (!goal || String(goal.version_id) !== String(body.version_id)) {
689
- return res.fail('bad_goal_id', { status: 400, message: `goal_id ${body.goal_id} does not exist on version '${body.version_id}'.` });
690
- }
691
- }
692
- try {
693
- const task = await db.createTask({
694
- versionId: body.version_id,
695
- title: body.title,
696
- description: body.description || '',
697
- status: body.status || 'backlog',
698
- touches,
699
- estMinutes: body.est_minutes ?? null,
700
- estCostUsd: body.est_cost_usd ?? null,
701
- manualDegree: body.manual_degree ?? null,
702
- priority: body.priority ?? null,
703
- automationTag: body.automation_tag ?? null,
704
- creditsReward: body.credits_reward ?? 0,
705
- source: effectiveSource,
706
- sourceRef: body.source_ref ?? null,
707
- valueSummary: body.value_summary ?? null,
708
- kind,
709
- discipline,
710
- parallelSafe: typeof body.parallel_safe === 'boolean' ? body.parallel_safe : null,
711
- newcomerFriendly: body.newcomer_friendly === true,
712
- securitySensitive: body.security_sensitive === true,
713
- needsMigration,
714
- requiresRank: body.requires_rank ?? null,
715
- moduleKey: body.module_key ?? null,
716
- goalId: body.goal_id ?? null,
717
- });
718
- // task 438: link resolved criteria (source='planning' — task-create is the
719
- // planning vector). The ids were version-checked above, so this is a single
720
- // idempotent batch insert; surface the linked criteria in the response so the
721
- // caller (and planning-session) can confirm the task is attributed.
722
- let criteria = [];
723
- if (criterionIdsResolved.length > 0) {
724
- await db.linkTaskCriteria(task.id, criterionIdsResolved, 'planning');
725
- criteria = await db.getTaskCriteria(task.id);
726
- }
727
- // task 1677 / ADR 0096: surface the assigned credits_reward so the creator sees
728
- // the drachmae value the task was born with — auto-assigned at creation when the
729
- // caller supplied no positive value (0/absent flows into createTask as "unset").
730
- // task 1957: credits_reward is an ESTIMATE, not a guaranteed payout. The hall
731
- // renders it as "<Currency> Est." until the task settles (confirmed/shipped),
732
- // then switches to the real ledger figure (credits_awarded from the task read).
733
- // task 2165 — the task landed under a goal but the caller linked no
734
- // criterion → SUGGEST the most likely done-when criterion(s) for the author
735
- // to confirm (they link via POST /tasks/:id/criteria). Advisory only, never
736
- // auto-linked ("suggest, human picks"). Best-effort: a suggestion hiccup
737
- // must not fail an otherwise-successful create. The general catch-all goal
738
- // usually has no criteria, so this is naturally empty there.
739
- let suggested_criteria = [];
740
- if (criteria.length === 0 && task.goal_id != null) {
741
- try {
742
- const goalCriteria = await db.listCriteriaForGoal(task.goal_id);
743
- suggested_criteria = suggestCriteria(
744
- goalCriteria,
745
- { title: body.title, description: body.description },
746
- { limit: 3 }
747
- );
748
- } catch (e) {
749
- console.error('[gds] POST /tasks suggest-criteria (non-blocking)', e && e.message);
750
- }
751
- }
752
- // task 1002539 — the orphan-task advisory (the /goal-create auto-pickup
753
- // hook). Nothing 4xxs when a task has no goal, so surface it here instead of
754
- // blocking. Non-blocking by construction — see goal-advisory.js.
755
- const goal_advisory = await buildGoalAdvisory({ db, versionId: body.version_id, task });
756
- res.status(201).json({
757
- task,
758
- credits_reward: task.credits_reward,
759
- needs_migration: needsMigration,
760
- criteria,
761
- suggested_criteria,
762
- goal_advisory, est_advisory: await require('../est-advisory').buildEstAdvisory({ db, task }), // task 1003148
763
- });
764
- } catch (err) {
765
- console.error('[gds] POST /tasks', err);
766
- res.fail('create_failed', { status: 500, message: 'internal error' });
767
- }
768
- });
769
-
770
- // PATCH /tasks/:id — supports body.parent_task_id (V2 C2), body.kind (V2 B1),
771
- // body.discipline (mig 014), body.parallel_safe
772
- // (mig 028), and body.newcomer_friendly (#360 / ADR 0018 + V3.R15 #239 —
773
- // body.touches was removed in touches[] cleanup [4/8] (task 879) along with
774
- // the drift scanner —
775
- // open a task to the Xenos sandbox; alias body.xenos_claimable accepted for
776
- // pre-rename callers). Pass parent_task_id: null to detach; parallel_safe
777
- // accepts true|false|null; newcomer_friendly is a boolean. Validation lives
778
- // in hierarchy.js for parent; the rest are validated inline.
779
- // rank: metic+archon — edit work (incl. newcomer_friendly flag); ADR 0090 (was ADR 0016 archon).
780
- router.patch('/tasks/:id', auth.requireBuilder, auth.requirePermission('task.edit'), async (req, res) => {
781
- if (validateOrRespond(req, res, {
782
- kind: { type: 'string', maxLength: 64 },
783
- discipline: { type: 'string', maxLength: 64 },
784
- requires_rank: { type: 'string', maxLength: 16 }, // task 1498 / ADR 0084 — override the per-task rank floor (upward only)
785
- module_key: { type: 'string', maxLength: 64 }, // BV1.R53 (task 1422) — module affinity label
786
- goal_id: { type: 'integer', min: 1 }, // task 1763 — (re)assign the task's goal (must be in the task's version)
787
- // touches[] cleanup [4/8] (task 879): touches is no longer PATCH-able —
788
- // the drift scanner + mid-claim expansion were removed. The rest are
789
- // checked inline below but must be DECLARED so strict-mode (R11/#1998)
790
- // accepts them instead of rejecting them as unknown fields.
791
- parent_task_id: {}, // number id, or null to detach (hasOwnProperty-gated below)
792
- parallel_safe: {}, // true/false/null — checked inline
793
- newcomer_friendly: { type: 'boolean' }, // V3.R15 — Xenos-claimable flag
794
- xenos_claimable: { type: 'boolean' }, // pre-rename alias for newcomer_friendly
795
- security_sensitive: { type: 'boolean' }, // task 990 — Archon-only ship broadcast
796
- automation_tag: { type: 'string', maxLength: 64 },
797
- needs_migration: { type: 'boolean' }, // task 1002648 — correct the create-time heuristic's flag (the claim-time allocator keys off it)
798
- // task 1002647 (map s4.3): the task body is a load-bearing builder prompt
799
- // and was settable only at create — no route edited it at any rank. Now
800
- // editable under this route's task.edit permission. minLength 1: a body
801
- // can be rewritten, never blanked (and null is refused by the type check).
802
- // Each edit's new body lands in audit_log via the write-audit middleware.
803
- description: { type: 'string', minLength: 1, maxLength: LIMITS.DESCRIPTION },
804
- // task 1002614 (map D-18): DECLARED so strict-mode admits the request far
805
- // enough to reach the deliberate status_not_patchable hint below (#514) —
806
- // the hint had been unreachable dead code behind unknown_field since the
807
- // R11 strict-validation sweep landed after it.
808
- status: {},
809
- })) return;
810
- const id = parseId(req, res, { code: 'bad_task_id', positiveInt: true });
811
- if (id === null) return;
812
- const body = req.body || {};
813
- const hasParent = Object.prototype.hasOwnProperty.call(body, 'parent_task_id');
814
- const hasKind = Object.prototype.hasOwnProperty.call(body, 'kind');
815
- const hasDiscipline = Object.prototype.hasOwnProperty.call(body, 'discipline');
816
- // V3.R15 #239: newcomer_friendly is the canonical name; xenos_claimable is
817
- // the pre-rename alias and still accepted. Either presence triggers the
818
- // same update path.
819
- const hasNewcomerFriendly = Object.prototype.hasOwnProperty.call(body, 'newcomer_friendly');
820
- const hasXenosClaimable = Object.prototype.hasOwnProperty.call(body, 'xenos_claimable');
821
- const hasNewcomerOrXenos = hasNewcomerFriendly || hasXenosClaimable;
822
- const hasParallelSafe = Object.prototype.hasOwnProperty.call(body, 'parallel_safe');
823
- const hasSecuritySensitive = Object.prototype.hasOwnProperty.call(body, 'security_sensitive');
824
- const hasAutomationTag = Object.prototype.hasOwnProperty.call(body, 'automation_tag');
825
- const hasNeedsMigration = Object.prototype.hasOwnProperty.call(body, 'needs_migration');
826
- const hasRequiresRank = Object.prototype.hasOwnProperty.call(body, 'requires_rank');
827
- const hasModuleKey = Object.prototype.hasOwnProperty.call(body, 'module_key');
828
- const hasGoalId = Object.prototype.hasOwnProperty.call(body, 'goal_id');
829
- const hasDescription = Object.prototype.hasOwnProperty.call(body, 'description');
830
- // #514: status is a lifecycle field, not a PATCH-able column — it moves only
831
- // through the dedicated transition routes. The #506 session tried
832
- // `PATCH status=ready`, got the generic no_supported_fields error, and had
833
- // to go spelunking for the promote route. Detect status explicitly and point
834
- // the caller straight at the right endpoint.
835
- const STATUS_HINT =
836
- 'status is not editable via PATCH; use POST /tasks/:id/promote (backlog->ready), ' +
837
- '/tasks/:id/confirm, /tasks/:id/ship, or /claims/:id/resolve to move a task through its lifecycle.';
838
- if (Object.prototype.hasOwnProperty.call(body, 'status')) {
839
- return res.fail('status_not_patchable', { status: 400, message: STATUS_HINT });
840
- }
841
- if (!hasParent && !hasKind && !hasDiscipline && !hasNewcomerOrXenos && !hasParallelSafe && !hasSecuritySensitive && !hasAutomationTag && !hasNeedsMigration && !hasRequiresRank && !hasModuleKey && !hasGoalId && !hasDescription) {
842
- return res.fail('no_supported_fields', { status: 400, message: 'PATCH /tasks/:id supports parent_task_id, kind, discipline, description, parallel_safe, security_sensitive, automation_tag, needs_migration, requires_rank, module_key, goal_id, and newcomer_friendly (alias: xenos_claimable). ' + STATUS_HINT });
843
- }
844
- // ---- task 1002609 (G-22): settle EVERY gate and validation before the ----
845
- // ---- FIRST write. The previous shape validated-and-wrote per field in ----
846
- // sequence, so a body mixing a valid early field with a refused later one
847
- // committed the early write and then 403/400'd — state changed on a request
848
- // the caller was told was refused (a Metic sending { parallel_safe,
849
- // security_sensitive } had parallel_safe committed before rank_forbidden).
850
- // Authorization, type/enum checks, and the goal existence/version reads all
851
- // run here; writes begin only after the last validation `return`.
852
- // (Documented residual: parent_task_id validates inside hierarchy.attachParent
853
- // at the end — its checks are atomic with its OWN write, but a refusal there
854
- // still lands after the field writes below. No authorization gate is
855
- // involved in that path; the handler's only rank gate is hoisted here.)
856
- if (hasParallelSafe && body.parallel_safe !== null && typeof body.parallel_safe !== 'boolean') {
857
- return res.fail('bad_parallel_safe', { status: 400, message: 'parallel_safe must be true, false, or null' });
858
- }
859
- if (hasSecuritySensitive) {
860
- // task 990: explicit Archon-only-broadcast flag. Boolean only (no null) —
861
- // it gates whether ship.js routes the broadcast to the Archon-only
862
- // #security-ship-feed channel instead of the public feeds.
863
- // ADR 0090: PATCH /tasks/:id opened to Metic+, but security_sensitive is a
864
- // SECURITY control (broadcast suppression + it feeds the auto-derived rank
865
- // floor, ADR 0084), not a planning field — so it stays Archon-only via this
866
- // in-handler gate. A Metic editing any other field is unaffected; only a
867
- // requires_rank-style escalation surface is withheld. (Setting it at create
868
- // time is safe — a brand-new task has no existing sensitivity to clear.)
869
- if (!auth.rankMeetsThreshold(req.builder.rank, ['archon'])) {
870
- return res.fail('rank_forbidden', 403, { required: ['archon'], actual: req.builder.rank, reason: 'security_sensitive is an Archon-only security control (ADR 0090); a Metic may edit every other task field.' });
871
- }
872
- if (typeof body.security_sensitive !== 'boolean') {
873
- return res.fail('bad_security_sensitive', { status: 400, message: 'security_sensitive must be a boolean' });
874
- }
875
- }
876
- const VALID_AUTOMATION_TAGS = new Set(['overnight-safe', 'live-only', 'needs-input']);
877
- if (hasAutomationTag) {
878
- const tag = body.automation_tag;
879
- if (tag !== null && (typeof tag !== 'string' || !VALID_AUTOMATION_TAGS.has(tag))) {
880
- return res.fail('bad_automation_tag', { status: 400, message: 'automation_tag must be one of overnight-safe | live-only | needs-input, or null to clear', details: { valid: Array.from(VALID_AUTOMATION_TAGS) } });
881
- }
882
- }
883
- if (hasModuleKey) {
884
- // BV1.R53 (task 1422): assign or clear the module affinity label. null clears.
885
- if (body.module_key !== null && !isModuleKey(body.module_key)) {
886
- return res.fail('bad_module_key', { status: 400, message: `module_key must be one of the known module keys, or null to clear. Valid keys: ${moduleKeys().join(', ')}.`, details: { valid: moduleKeys() } });
887
- }
888
- }
889
- if (hasGoalId) {
890
- // task 1763: (re)assign the task's goal — the documented "Archon/Metic
891
- // mechanism" to set goal_id (this route is metic+archon). A task belongs to
892
- // exactly one goal WITHIN its own version: both the per-version general goals
893
- // and goal-authored tasks hold this, and the /status rollup walks
894
- // version→goal→task, so a cross-version link would drop the task out of its
895
- // version's rollup. Refuse that (409). goal_id is never cleared to null here
896
- // (that reintroduces an orphan) — a positive integer is required.
897
- const goalId = body.goal_id;
898
- if (!Number.isInteger(goalId) || goalId <= 0) {
899
- return res.fail('bad_goal_id', { status: 400, message: 'goal_id must be a positive integer — a task belongs to exactly one goal.' });
900
- }
901
- try {
902
- const task = await db.getTask(id);
903
- if (!task) return res.fail('task_not_found', 404);
904
- const goal = await db.getGoal(goalId);
905
- if (!goal) return res.fail('goal_not_found', { status: 404, message: `No goal #${goalId}.` });
906
- if (String(goal.version_id) !== String(task.version_id)) {
907
- return res.fail('goal_version_mismatch', { status: 409, message: `Goal #${goalId} is in version '${goal.version_id}' but task #${id} is in '${task.version_id}'. A task can only join a goal in its own version.` });
908
- }
909
- } catch (err) {
910
- console.error('[gds] PATCH /tasks/:id goal_id', err);
911
- return res.fail('patch_failed', { status: 500, message: 'internal error' });
912
- }
913
- }
914
- if (hasRequiresRank) {
915
- // task 1498 / ADR 0084: the Archon override of the per-task rank floor.
916
- // Validate the enum here; db.updateTaskRequiresRank clamps the value UP to
917
- // the auto-derived floor (touches + security_sensitive), so a PATCH can
918
- // only RAISE the gate — never open a sensitive task to a lower rank.
919
- const LIVE_RANKS = new Set(db.LIVE_RANK_LADDER);
920
- if (typeof body.requires_rank !== 'string' || !LIVE_RANKS.has(body.requires_rank)) {
921
- return res.fail('bad_requires_rank', { status: 400, message: `requires_rank must be one of ${db.LIVE_RANK_LADDER.join(' | ')}. An override can only raise the floor; it is clamped up to the auto-derived minimum.`, details: { valid: db.LIVE_RANK_LADDER } });
922
- }
923
- }
924
- if (hasKind && !VALID_KINDS.has(body.kind)) {
925
- return badKind(res);
926
- }
927
- if (hasDiscipline && !VALID_DISCIPLINES.has(body.discipline)) {
928
- return res.fail('bad_discipline', 400, { valid: Array.from(VALID_DISCIPLINES) });
929
- }
930
- if (hasNewcomerOrXenos) {
931
- // V3.R15 #239: prefer newcomer_friendly (canonical); fall back to
932
- // xenos_claimable (pre-rename alias). Both map to the same DB column.
933
- const incoming = hasNewcomerFriendly ? body.newcomer_friendly : body.xenos_claimable;
934
- if (typeof incoming !== 'boolean') {
935
- const field = hasNewcomerFriendly ? 'newcomer_friendly' : 'xenos_claimable';
936
- return res.fail(`bad_${field}`, { status: 400, message: `${field} must be a boolean` });
937
- }
938
- }
939
- // ---- all gates passed: apply the writes. Order preserved from the old ----
940
- // shape — requires_rank still runs AFTER security_sensitive so a
941
- // same-request flag flip is already reflected in the derived floor.
942
- let updated = null;
943
- if (hasParallelSafe) {
944
- try {
945
- updated = await db.updateTaskParallelSafe(id, body.parallel_safe);
946
- if (!updated) return res.fail('task_not_found', 404);
947
- } catch (err) {
948
- console.error('[gds] PATCH /tasks/:id parallel_safe', err);
949
- return res.fail('patch_failed', { status: 500, message: 'internal error' });
950
- }
951
- }
952
- if (hasSecuritySensitive) {
953
- try {
954
- updated = await db.updateTaskSecuritySensitive(id, body.security_sensitive);
955
- if (!updated) return res.fail('task_not_found', 404);
956
- } catch (err) {
957
- console.error('[gds] PATCH /tasks/:id security_sensitive', err);
958
- return res.fail('patch_failed', { status: 500, message: 'internal error' });
959
- }
960
- }
961
- if (hasAutomationTag) {
962
- try {
963
- updated = await db.updateTaskAutomationTag(id, body.automation_tag);
964
- if (!updated) return res.fail('task_not_found', 404);
965
- } catch (err) {
966
- console.error('[gds] PATCH /tasks/:id automation_tag', err);
967
- return res.fail('patch_failed', { status: 500, message: 'internal error' });
968
- }
969
- }
970
- if (hasNeedsMigration) {
971
- try {
972
- updated = await db.updateTaskNeedsMigration(id, body.needs_migration);
973
- if (!updated) return res.fail('task_not_found', 404);
974
- } catch (err) {
975
- console.error('[gds] PATCH /tasks/:id needs_migration', err);
976
- return res.fail('patch_failed', { status: 500, message: 'internal error' });
977
- }
978
- }
979
- if (hasModuleKey) {
980
- try {
981
- updated = await db.updateTaskModuleKey(id, body.module_key ?? null);
982
- if (!updated) return res.fail('task_not_found', 404);
983
- } catch (err) {
984
- console.error('[gds] PATCH /tasks/:id module_key', err);
985
- return res.fail('patch_failed', { status: 500, message: 'internal error' });
986
- }
987
- }
988
- if (hasDescription) {
989
- try {
990
- updated = await db.updateTaskDescription(id, body.description);
991
- if (!updated) return res.fail('task_not_found', 404);
992
- } catch (err) {
993
- console.error('[gds] PATCH /tasks/:id description', err);
994
- return res.fail('patch_failed', { status: 500, message: 'internal error' });
995
- }
996
- }
997
- if (hasGoalId) {
998
- try {
999
- updated = await db.updateTaskGoal(id, body.goal_id);
1000
- if (!updated) return res.fail('task_not_found', 404);
1001
- } catch (err) {
1002
- console.error('[gds] PATCH /tasks/:id goal_id', err);
1003
- return res.fail('patch_failed', { status: 500, message: 'internal error' });
1004
- }
1005
- }
1006
- if (hasRequiresRank) {
1007
- try {
1008
- updated = await db.updateTaskRequiresRank(id, body.requires_rank);
1009
- if (!updated) return res.fail('task_not_found', 404);
1010
- } catch (err) {
1011
- console.error('[gds] PATCH /tasks/:id requires_rank', err);
1012
- return res.fail('patch_failed', { status: 500, message: 'internal error' });
1013
- }
1014
- }
1015
- if (hasKind) {
1016
- try {
1017
- updated = await db.updateTaskKind(id, body.kind);
1018
- if (!updated) return res.fail('task_not_found', 404);
1019
- } catch (err) {
1020
- console.error('[gds] PATCH /tasks/:id kind', err);
1021
- return res.fail('patch_failed', { status: 500, message: 'internal error' });
1022
- }
1023
- }
1024
- if (hasDiscipline) {
1025
- try {
1026
- updated = await db.updateTaskDiscipline(id, body.discipline);
1027
- if (!updated) return res.fail('task_not_found', 404);
1028
- } catch (err) {
1029
- console.error('[gds] PATCH /tasks/:id discipline', err);
1030
- return res.fail('patch_failed', { status: 500, message: 'internal error' });
1031
- }
1032
- }
1033
- if (hasNewcomerOrXenos) {
1034
- const incoming = hasNewcomerFriendly ? body.newcomer_friendly : body.xenos_claimable;
1035
- try {
1036
- updated = await db.updateTaskNewcomerFriendly(id, incoming);
1037
- if (!updated) return res.fail('task_not_found', 404);
1038
- } catch (err) {
1039
- console.error('[gds] PATCH /tasks/:id newcomer_friendly', err);
1040
- return res.fail('patch_failed', { status: 500, message: 'internal error' });
1041
- }
1042
- }
1043
- if (!hasParent) return res.json({ task: updated });
1044
- try {
1045
- const task = await hierarchy.attachParent(id, body.parent_task_id);
1046
- res.json({ task });
1047
- } catch (err) {
1048
- if (err && err.code) {
1049
- const status =
1050
- err.code === 'TASK_NOT_FOUND' ? 404 :
1051
- err.code === 'PARENT_NOT_FOUND' ? 404 :
1052
- err.code === 'SELF_PARENT' ? 409 :
1053
- err.code === 'VERSION_MISMATCH' ? 409 :
1054
- err.code === 'PARENT_HAS_PARENT' ? 409 :
1055
- err.code === 'TARGET_HAS_CHILDREN' ? 409 :
1056
- 500;
1057
- return res.fail(err.code, status, { ...err });
1058
- }
1059
- console.error('[gds] PATCH /tasks/:id', err);
1060
- res.fail('patch_failed', { status: 500, message: 'internal error' });
1061
- }
1062
- });
369
+ registerTaskWriteRoutes(router); // POST /tasks + PATCH /tasks/:id (task 1003206)
1063
370
 
1064
371
  // rank: metic+archon — backlog→ready promotion; scope mutation; ADR 0090 (was archon).
1065
372
  //