@idea-base/mcp-server 2.0.0 → 2.2.0
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.
- package/README.md +18 -2
- package/package.json +1 -1
- package/src/index.js +406 -12
package/README.md
CHANGED
|
@@ -85,8 +85,24 @@ Ask Claude to manage your projects:
|
|
|
85
85
|
| Tool | Description |
|
|
86
86
|
|------|-------------|
|
|
87
87
|
| `log_time` | Log time against a task with notes |
|
|
88
|
-
| `start_working` |
|
|
89
|
-
| `
|
|
88
|
+
| `start_working` | Open a timed work session on a task, and mark yourself actively working (surfaces the saved resume context + recent work notes so a cold session re-orients). Calling it twice never opens a second session — it resumes or reports the one you have |
|
|
89
|
+
| `pause_working` | Pause the open session without ending it, with a required `reason` |
|
|
90
|
+
| `resume_working` | Close the pause and carry on in the same session |
|
|
91
|
+
| `stop_working` | Close the session with a UTC end instant and drop the active flag (optionally capture a `note` and/or `resume_context` on the way out). Fails if you have no open session |
|
|
92
|
+
|
|
93
|
+
**Sessions are measured, and the pause reason decides the number.** `start_working`
|
|
94
|
+
records a UTC start instant, `stop_working` a UTC end instant, and each pause in
|
|
95
|
+
between records both its boundaries plus why work stopped:
|
|
96
|
+
|
|
97
|
+
| `reason` | Effect on worked time |
|
|
98
|
+
|---|---|
|
|
99
|
+
| `waiting_on_human` | **Excluded.** A person has to act before you can continue |
|
|
100
|
+
| `waiting_on_agent` | **Counted.** A sub-agent blocked on another *active* sub-agent is still working |
|
|
101
|
+
| `other` | Counted |
|
|
102
|
+
|
|
103
|
+
Only waiting on the human is not working. Use `pause_working` rather than
|
|
104
|
+
`stop_working` whenever you intend to carry on — `stop_working` ends the session
|
|
105
|
+
and the reason is lost.
|
|
90
106
|
|
|
91
107
|
### Activity & Audit Trail
|
|
92
108
|
|
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -226,6 +226,7 @@ async function apiRequest(endpoint, options = {}) {
|
|
|
226
226
|
|
|
227
227
|
if (!response.ok) {
|
|
228
228
|
let message = `API error: ${response.status}`;
|
|
229
|
+
let code = null;
|
|
229
230
|
const contentType = (response.headers.get('content-type') || '');
|
|
230
231
|
if (contentType.includes('json')) {
|
|
231
232
|
try {
|
|
@@ -233,11 +234,20 @@ async function apiRequest(endpoint, options = {}) {
|
|
|
233
234
|
if (typeof body?.error === 'string' && body.error) {
|
|
234
235
|
message = body.error.slice(0, MAX_ERROR_CHARS);
|
|
235
236
|
}
|
|
237
|
+
// Machine-readable discriminator, when the endpoint supplies one. A
|
|
238
|
+
// handler needs to tell "you have no open session" (a real answer the
|
|
239
|
+
// caller must see) apart from "that endpoint is not deployed yet"
|
|
240
|
+
// (an infrastructure fact it should degrade around), and string-matching
|
|
241
|
+
// a prose error message to decide that is how quiet bugs get written.
|
|
242
|
+
if (typeof body?.code === 'string' && body.code) code = body.code;
|
|
236
243
|
} catch {
|
|
237
244
|
// fall through to the generic status message
|
|
238
245
|
}
|
|
239
246
|
}
|
|
240
|
-
|
|
247
|
+
const error = new Error(redact(message));
|
|
248
|
+
error.status = response.status;
|
|
249
|
+
if (code) error.code = code;
|
|
250
|
+
throw error;
|
|
241
251
|
}
|
|
242
252
|
|
|
243
253
|
return parseJsonBody(text, response);
|
|
@@ -433,12 +443,34 @@ const tools = [
|
|
|
433
443
|
},
|
|
434
444
|
assignee_user_id: {
|
|
435
445
|
type: 'string',
|
|
436
|
-
description: 'User ID to assign the task to. Must be a member of the same account. Omit to leave the task unassigned.',
|
|
446
|
+
description: 'User ID to assign the task to. Must be a member of the same account. Omit to leave the task unassigned. For a SECOND (or third, ...) assignee, do not call this again — use add_assignee after creation, since this field only ever sets a single owner at create time.',
|
|
437
447
|
},
|
|
438
448
|
parent_task_id: {
|
|
439
449
|
type: 'number',
|
|
440
450
|
description: 'Make this a SUBTASK of the given task. The parent must be in the same project, and a subtask cannot itself have subtasks (one level only). A parent with subtasks takes its status from them: any subtask in progress makes the parent in progress, and all subtasks done makes it ready to complete.',
|
|
441
451
|
},
|
|
452
|
+
verification_mode: {
|
|
453
|
+
type: 'string',
|
|
454
|
+
enum: ['manual', 'ai_review', 'ci_required', 'all'],
|
|
455
|
+
description: 'Gate this task\'s own completion. "manual" (default) allows a plain status change to done. "ai_review" or "all" BLOCK marking the task done (403 VERIFICATION_REQUIRED) until ai_completion_score reaches 0.8 — run "Verify with AI" first. "ci_required" or "all" BLOCK it until a PR is linked (github_pr_url) with passing CI. Setting this arms a real check against your own future attempt to close the task.',
|
|
456
|
+
},
|
|
457
|
+
github_pr_url: {
|
|
458
|
+
type: 'string',
|
|
459
|
+
description: 'URL of the GitHub Pull Request implementing this task. Used to fetch the PR diff for AI verification and to match incoming CI status (check_run/check_suite/workflow_run webhooks) back to this task.',
|
|
460
|
+
},
|
|
461
|
+
sort_order: {
|
|
462
|
+
type: 'number',
|
|
463
|
+
description: 'Position among the project\'s tasks (lower sorts first). NOT wired on the REST create path — POST /api/projects/:id/tasks always assigns MAX(sort_order)+1 and ignores this field on create. Set it after creation with update_task instead.',
|
|
464
|
+
},
|
|
465
|
+
is_milestone: {
|
|
466
|
+
type: 'boolean',
|
|
467
|
+
description: 'Mark this task as a milestone, drawn differently on the Gantt view. NOT wired on the REST create path — POST /api/projects/:id/tasks does not accept this field; the column is left at its default (0). Set it after creation with update_task instead.',
|
|
468
|
+
},
|
|
469
|
+
required_ci_checks: {
|
|
470
|
+
type: 'array',
|
|
471
|
+
items: { type: 'string' },
|
|
472
|
+
description: 'Which named CI checks (matched case-insensitively as a substring of the check name) must pass for "ci_required"/"all" verification_mode to consider CI satisfied — e.g. ["build","test"]. Read by GET /api/tasks/:id/ci-status\'s resolveRequiredChecks (app/functions/api/tasks/[id]/ci-status.js:13-32), NOT by the GitHub webhook handler, which sets ci_status unconditionally from whichever single check/workflow event just arrived. Omit to leave unset (falls back to the project\'s own required_ci_checks, then to requiring every check). NOT wired on the REST create path — POST /api/projects/:id/tasks does not accept this field. Set it after creation with update_task instead.',
|
|
473
|
+
},
|
|
442
474
|
},
|
|
443
475
|
required: ['project_id', 'title'],
|
|
444
476
|
},
|
|
@@ -483,7 +515,29 @@ const tools = [
|
|
|
483
515
|
},
|
|
484
516
|
assignee_user_id: {
|
|
485
517
|
type: 'string',
|
|
486
|
-
description: 'User ID to assign the task to,
|
|
518
|
+
description: 'User ID to assign the task to, REPLACING THE WHOLE assignee set (every non-active assignment row is deleted, active workers spared) — use this only when you want exactly one owner. Must be a member of the same account. Pass an empty string to unassign everyone (active workers still spared). To ADD a second assignee without touching the first, use add_assignee instead; to remove just one, use remove_assignee.',
|
|
519
|
+
},
|
|
520
|
+
verification_mode: {
|
|
521
|
+
type: 'string',
|
|
522
|
+
enum: ['manual', 'ai_review', 'ci_required', 'all'],
|
|
523
|
+
description: 'Gate this task\'s own completion. "manual" (default) allows a plain status change to done. "ai_review" or "all" BLOCK marking the task done (403 VERIFICATION_REQUIRED) until ai_completion_score reaches 0.8 — run "Verify with AI" first. "ci_required" or "all" BLOCK it until a PR is linked (github_pr_url) with passing CI. Setting this arms a real check against your own future attempt to close the task.',
|
|
524
|
+
},
|
|
525
|
+
github_pr_url: {
|
|
526
|
+
type: 'string',
|
|
527
|
+
description: 'URL of the GitHub Pull Request implementing this task. Used to fetch the PR diff for AI verification and to match incoming CI status back to this task. Pass an empty string to clear it.',
|
|
528
|
+
},
|
|
529
|
+
sort_order: {
|
|
530
|
+
type: 'number',
|
|
531
|
+
description: 'Position among the project\'s tasks (lower sorts first). Drives the order returned by list_tasks and the board/list views.',
|
|
532
|
+
},
|
|
533
|
+
is_milestone: {
|
|
534
|
+
type: 'boolean',
|
|
535
|
+
description: 'Mark this task as a milestone, drawn differently on the Gantt view. Pass false to unmark it.',
|
|
536
|
+
},
|
|
537
|
+
required_ci_checks: {
|
|
538
|
+
type: 'array',
|
|
539
|
+
items: { type: 'string' },
|
|
540
|
+
description: 'Which named CI checks (matched case-insensitively as a substring of the check name) must pass for "ci_required"/"all" verification_mode to consider CI satisfied — e.g. ["build","test"]. Read by GET /api/tasks/:id/ci-status\'s resolveRequiredChecks (app/functions/api/tasks/[id]/ci-status.js:13-32), NOT by the GitHub webhook handler, which sets ci_status unconditionally from whichever single check/workflow event just arrived regardless of this field. Stored as a JSON array. Pass an empty array to require every check; this tool has no way to clear the column back to "inherit from project" (the REST API reserves that for an empty string, which this array-typed field cannot send).',
|
|
487
541
|
},
|
|
488
542
|
blocked_by: {
|
|
489
543
|
type: 'array',
|
|
@@ -521,6 +575,90 @@ const tools = [
|
|
|
521
575
|
required: ['task_id', 'status'],
|
|
522
576
|
},
|
|
523
577
|
},
|
|
578
|
+
{
|
|
579
|
+
name: 'add_assignee',
|
|
580
|
+
description: 'Assign a user to a task WITHOUT disturbing anyone already assigned. task_assignments holds one row per (task_id, user_id) — POST /api/tasks/:id/assignments upserts exactly that one row and leaves every other assignee alone, unlike update_task\'s assignee_user_id (which REPLACES the whole set — use that only when you actually want a single owner). This is the fix for the defect where a second MCP assignment silently dropped the first.\n\nIdempotent and is_active-safe: this checks the task\'s current assignees first, and if the user already holds a row (assigned OR actively working) it does nothing and says so, rather than re-POSTing. That matters because the REST endpoint\'s upsert OVERWRITES is_active on conflict — a blind re-assign of someone with a running timer (is_active=1, set by start_working) would silently stop their clock. Skipping the no-op re-POST is what keeps assignment and active-work presence independent.\n\nRejects a user who is not a member of this account (404, surfaced as an error) rather than creating a dangling assignment.',
|
|
581
|
+
inputSchema: {
|
|
582
|
+
type: 'object',
|
|
583
|
+
properties: {
|
|
584
|
+
task_id: {
|
|
585
|
+
type: 'number',
|
|
586
|
+
description: 'The ID of the task to assign the user to.',
|
|
587
|
+
},
|
|
588
|
+
user_id: {
|
|
589
|
+
type: 'string',
|
|
590
|
+
description: 'User ID to add as an assignee. Must be a member of the same account.',
|
|
591
|
+
},
|
|
592
|
+
},
|
|
593
|
+
required: ['task_id', 'user_id'],
|
|
594
|
+
},
|
|
595
|
+
},
|
|
596
|
+
{
|
|
597
|
+
name: 'remove_assignee',
|
|
598
|
+
description: 'Remove exactly one user\'s assignment from a task, leaving every other assignee untouched. DELETE /api/tasks/:id/assignments?user_id=... removes that one task_assignments row outright (both the assignment and any is_active flag on it) — this is a full unassign, not stop_working\'s "still assigned, no longer active" (use stop_working to end a work session without unassigning).',
|
|
599
|
+
inputSchema: {
|
|
600
|
+
type: 'object',
|
|
601
|
+
properties: {
|
|
602
|
+
task_id: {
|
|
603
|
+
type: 'number',
|
|
604
|
+
description: 'The ID of the task to remove the assignment from.',
|
|
605
|
+
},
|
|
606
|
+
user_id: {
|
|
607
|
+
type: 'string',
|
|
608
|
+
description: 'User ID to remove from the task\'s assignees.',
|
|
609
|
+
},
|
|
610
|
+
},
|
|
611
|
+
required: ['task_id', 'user_id'],
|
|
612
|
+
},
|
|
613
|
+
},
|
|
614
|
+
{
|
|
615
|
+
name: 'verify_task',
|
|
616
|
+
description: 'Run AI verification: grades this task\'s acceptance_criteria against the diff of its linked PR (a Haiku relevance pre-screen, then a Sonnet deep review) and PERSISTS the result to ai_completion_score / ai_completion_notes. This is exactly what update_task_status checks when the task\'s verification_mode is "ai_review" or "all" — call this to satisfy that gate yourself rather than repeatedly hitting 403 VERIFICATION_REQUIRED with no way through it.\n\nCOSTS MONEY — DO NOT LOOP THIS. Every call is metered via recordAIUsage and recorded to ai_generations (Sonnet, large PR-diff context) for audit. It runs FREE while your account is within its plan\'s included monthly verification cap; once over that cap each call is billed as AI credit overage — 2 credits per call on this account. Calling this repeatedly hoping for a higher score spends real credits for no guaranteed gain; if a genuine score lands below the 0.8 gate, that is a finding about the work, not a reason to retry.\n\nGRADES THE DIFF, NOT THE RUNNING SYSTEM. A high score means the PR\'s code changes look like they satisfy the criteria on paper — it is NOT proof the deployed/running behavior actually works. Never report this score as end-to-end verification; that is a human\'s job or an independent task-verify sub-agent\'s, not this tool\'s.\n\nRequires the task to already have non-empty acceptance_criteria (set via update_task) and a way to see the work: either the task\'s own github_pr_url (set via update_task) or a pr_url/work_description passed here. Fails with a clear message naming what is missing rather than grading an empty contract — refusal is free, no credits spent.',
|
|
617
|
+
inputSchema: {
|
|
618
|
+
type: 'object',
|
|
619
|
+
properties: {
|
|
620
|
+
task_id: {
|
|
621
|
+
type: 'number',
|
|
622
|
+
description: 'The ID of the task to verify. Must already have non-empty acceptance_criteria.',
|
|
623
|
+
},
|
|
624
|
+
pr_url: {
|
|
625
|
+
type: 'string',
|
|
626
|
+
description: 'GitHub PR URL to grade against, for this call only — does not overwrite the task\'s stored github_pr_url. Omit to use the task\'s own linked PR.',
|
|
627
|
+
},
|
|
628
|
+
work_description: {
|
|
629
|
+
type: 'string',
|
|
630
|
+
description: 'Free-text description of the completed work to grade against. Used ONLY as a fallback when no PR diff is available (no pr_url given here and the task has no github_pr_url); ignored whenever a PR diff can be fetched.',
|
|
631
|
+
},
|
|
632
|
+
},
|
|
633
|
+
required: ['task_id'],
|
|
634
|
+
},
|
|
635
|
+
},
|
|
636
|
+
{
|
|
637
|
+
name: 'record_verification_feedback',
|
|
638
|
+
description: 'Record whether the most recent verify_task verdict on this task was accurate — calibration data, not a correction. Attaches to the latest ai_generations row of type completion_review for this task, so verify_task must have run at least once first. Does NOT change the stored ai_completion_score or re-run any model — it costs nothing (no Anthropic call, just an audit-log row) and is safe to call as often as useful.',
|
|
639
|
+
inputSchema: {
|
|
640
|
+
type: 'object',
|
|
641
|
+
properties: {
|
|
642
|
+
task_id: {
|
|
643
|
+
type: 'number',
|
|
644
|
+
description: 'The ID of the task whose most recent verification you are giving feedback on.',
|
|
645
|
+
},
|
|
646
|
+
agreed: {
|
|
647
|
+
type: 'boolean',
|
|
648
|
+
description: 'Whether you agree the AI verdict (score + summary) was an accurate read of the work.',
|
|
649
|
+
},
|
|
650
|
+
actual_score: {
|
|
651
|
+
type: 'number',
|
|
652
|
+
description: 'Optional: the score (0.0-1.0) you believe was actually correct, if you disagree with the AI\'s score.',
|
|
653
|
+
},
|
|
654
|
+
notes: {
|
|
655
|
+
type: 'string',
|
|
656
|
+
description: 'Optional: why you agreed or disagreed.',
|
|
657
|
+
},
|
|
658
|
+
},
|
|
659
|
+
required: ['task_id', 'agreed'],
|
|
660
|
+
},
|
|
661
|
+
},
|
|
524
662
|
{
|
|
525
663
|
name: 'log_time',
|
|
526
664
|
description: 'Log time spent on a task. This triggers a real-time notification showing time was logged.',
|
|
@@ -622,7 +760,7 @@ const tools = [
|
|
|
622
760
|
},
|
|
623
761
|
{
|
|
624
762
|
name: 'start_working',
|
|
625
|
-
description: '
|
|
763
|
+
description: 'Start work on a task. Two things happen: you are shown to the team as actively working on it (and a todo task moves to in_progress), and a TIMED WORK SESSION is opened with a UTC start instant, so how long the task was actually worked becomes measurable instead of guessed.\n\nCalling it a second time on the same task NEVER opens a second session. If your session is paused it is resumed; if it is already running you are told so, with how long it has been going. The response always says which of the three happened.\n\nIt also orients a cold session: the response carries the task\'s resume context and its recent work notes, so read the whole thing before you start. Pair it with stop_working, and use pause_working when you have to wait on something.',
|
|
626
764
|
inputSchema: {
|
|
627
765
|
type: 'object',
|
|
628
766
|
properties: {
|
|
@@ -636,7 +774,7 @@ const tools = [
|
|
|
636
774
|
},
|
|
637
775
|
{
|
|
638
776
|
name: 'stop_working',
|
|
639
|
-
description: '
|
|
777
|
+
description: 'Stop working on a task: close your open work session with a UTC end instant, and drop the "actively working" flag (you stay assigned). The closed session is what makes the task\'s worked duration reportable.\n\nRequires an open session — if you have none this FAILS and writes nothing, rather than inventing a session with no start. Call start_working first.\n\nThis ENDS the session. If you are only waiting on something and intend to carry on afterwards, use pause_working instead: a pause keeps the session open and records why you stopped, which is what decides whether the interval counts as worked time.\n\nOptionally capture your state on the way out: pass `note` to append a work note and/or `resume_context` to overwrite the resume block, so the next session can pick up where you left off.',
|
|
640
778
|
inputSchema: {
|
|
641
779
|
type: 'object',
|
|
642
780
|
properties: {
|
|
@@ -656,6 +794,43 @@ const tools = [
|
|
|
656
794
|
required: ['task_id'],
|
|
657
795
|
},
|
|
658
796
|
},
|
|
797
|
+
{
|
|
798
|
+
name: 'pause_working',
|
|
799
|
+
description: 'Pause your open work session without ending it, and record WHY. Use this whenever work stops but is not finished — you asked the human a question, you are waiting on another agent, you are blocked on a build.\n\nTHE REASON IS THE POINT, and it decides the number:\n waiting_on_human — you are blocked on a person and CANNOT proceed. The paused interval is EXCLUDED from worked time.\n waiting_on_agent — you are blocked on another agent or an automated run that is itself active. The interval IS COUNTED as worked time: a sub-agent blocked on another active sub-agent is still working.\n other — anything else. Counted as worked.\n\nOnly waiting on the human is not working. Choose waiting_on_human ONLY when a person has to act before you can continue; if a machine or another agent is doing the work, it is waiting_on_agent.\n\nResume with resume_working. Do NOT use stop_working for a pause — that ends the session and the reason is lost.',
|
|
800
|
+
inputSchema: {
|
|
801
|
+
type: 'object',
|
|
802
|
+
properties: {
|
|
803
|
+
task_id: {
|
|
804
|
+
type: 'number',
|
|
805
|
+
description: 'The ID of the task whose session to pause',
|
|
806
|
+
},
|
|
807
|
+
reason: {
|
|
808
|
+
type: 'string',
|
|
809
|
+
enum: ['waiting_on_human', 'waiting_on_agent', 'other'],
|
|
810
|
+
description: 'Why work stopped. waiting_on_human EXCLUDES this interval from worked time; waiting_on_agent and other INCLUDE it. Required — there is no default, because the whole worked-time figure turns on this value.',
|
|
811
|
+
},
|
|
812
|
+
detail: {
|
|
813
|
+
type: 'string',
|
|
814
|
+
description: 'Optional free text: what specifically you are waiting on (e.g. "asked whether to merge PR #280").',
|
|
815
|
+
},
|
|
816
|
+
},
|
|
817
|
+
required: ['task_id', 'reason'],
|
|
818
|
+
},
|
|
819
|
+
},
|
|
820
|
+
{
|
|
821
|
+
name: 'resume_working',
|
|
822
|
+
description: 'Resume your paused work session — closes the pause by recording the UTC instant you came back, so the paused interval has both boundaries and can be measured. The session itself was never closed and keeps its original start.\n\nFails if the session is not paused, rather than pretending to resume something. start_working also resumes a paused session, so use whichever reads better; this one does not re-fetch the orientation block.',
|
|
823
|
+
inputSchema: {
|
|
824
|
+
type: 'object',
|
|
825
|
+
properties: {
|
|
826
|
+
task_id: {
|
|
827
|
+
type: 'number',
|
|
828
|
+
description: 'The ID of the task whose session to resume',
|
|
829
|
+
},
|
|
830
|
+
},
|
|
831
|
+
required: ['task_id'],
|
|
832
|
+
},
|
|
833
|
+
},
|
|
659
834
|
{
|
|
660
835
|
name: 'add_work_note',
|
|
661
836
|
description: 'Append a timestamped work note (status update) to a task\'s activity log. Use this to record progress so a future session can pick up where you left off (e.g. "finished auth handler, tests green, next: wire the callback"). Notes are append-only and never overwrite each other.',
|
|
@@ -897,7 +1072,12 @@ const toolHandlers = {
|
|
|
897
1072
|
};
|
|
898
1073
|
},
|
|
899
1074
|
|
|
900
|
-
async create_task({ project_id, title, description, acceptance_criteria, estimated_minutes, priority, start_date, due_date, assignee_user_id, parent_task_id }) {
|
|
1075
|
+
async create_task({ project_id, title, description, acceptance_criteria, estimated_minutes, priority, start_date, due_date, assignee_user_id, parent_task_id, verification_mode, github_pr_url, sort_order, is_milestone, required_ci_checks }) {
|
|
1076
|
+
// sort_order, is_milestone and required_ci_checks are forwarded for parity
|
|
1077
|
+
// with update_task, but POST /api/projects/:id/tasks does not destructure
|
|
1078
|
+
// any of the three — it always assigns MAX(sort_order)+1 and leaves
|
|
1079
|
+
// is_milestone/required_ci_checks at their column defaults. Sending them
|
|
1080
|
+
// here is a silent no-op on create; use update_task right after to set them.
|
|
901
1081
|
const task = await apiRequest(`/projects/${project_id}/tasks`, {
|
|
902
1082
|
method: 'POST',
|
|
903
1083
|
body: JSON.stringify({
|
|
@@ -910,6 +1090,11 @@ const toolHandlers = {
|
|
|
910
1090
|
due_date,
|
|
911
1091
|
assignee_user_id,
|
|
912
1092
|
parent_task_id,
|
|
1093
|
+
verification_mode,
|
|
1094
|
+
github_pr_url,
|
|
1095
|
+
sort_order,
|
|
1096
|
+
is_milestone,
|
|
1097
|
+
required_ci_checks,
|
|
913
1098
|
}),
|
|
914
1099
|
});
|
|
915
1100
|
return {
|
|
@@ -922,11 +1107,13 @@ const toolHandlers = {
|
|
|
922
1107
|
};
|
|
923
1108
|
},
|
|
924
1109
|
|
|
925
|
-
async update_task({ task_id, title, description, acceptance_criteria, estimated_minutes, priority, start_date, due_date, assignee_user_id, blocked_by, blocked_reason }) {
|
|
1110
|
+
async update_task({ task_id, title, description, acceptance_criteria, estimated_minutes, priority, start_date, due_date, assignee_user_id, blocked_by, blocked_reason, verification_mode, github_pr_url, sort_order, is_milestone, required_ci_checks }) {
|
|
926
1111
|
// Only forward what the caller actually named. An absent key leaves the
|
|
927
|
-
// field alone; an explicitly empty string is how a date
|
|
928
|
-
// cleared, and blocked_by is a REPLACE so an explicit []
|
|
929
|
-
// API — which is why these are `!== undefined` rather than
|
|
1112
|
+
// field alone; an explicitly empty string is how a date, the assignee or
|
|
1113
|
+
// the PR link is cleared, and blocked_by is a REPLACE so an explicit []
|
|
1114
|
+
// must reach the API — which is why these are `!== undefined` rather than
|
|
1115
|
+
// truthiness. is_milestone follows the same rule: `false` is a real value,
|
|
1116
|
+
// not an absence, and must still reach the API.
|
|
930
1117
|
const updates = {};
|
|
931
1118
|
if (title !== undefined) updates.title = title;
|
|
932
1119
|
if (description !== undefined) updates.description = description;
|
|
@@ -938,6 +1125,11 @@ const toolHandlers = {
|
|
|
938
1125
|
if (assignee_user_id !== undefined) updates.assignee_user_id = assignee_user_id;
|
|
939
1126
|
if (blocked_by !== undefined) updates.blocked_by = blocked_by;
|
|
940
1127
|
if (blocked_reason !== undefined) updates.blocked_reason = blocked_reason;
|
|
1128
|
+
if (verification_mode !== undefined) updates.verification_mode = verification_mode;
|
|
1129
|
+
if (github_pr_url !== undefined) updates.github_pr_url = github_pr_url;
|
|
1130
|
+
if (sort_order !== undefined) updates.sort_order = sort_order;
|
|
1131
|
+
if (is_milestone !== undefined) updates.is_milestone = is_milestone;
|
|
1132
|
+
if (required_ci_checks !== undefined) updates.required_ci_checks = required_ci_checks;
|
|
941
1133
|
|
|
942
1134
|
const task = await apiRequest(`/tasks/${task_id}`, {
|
|
943
1135
|
method: 'PUT',
|
|
@@ -968,6 +1160,103 @@ const toolHandlers = {
|
|
|
968
1160
|
};
|
|
969
1161
|
},
|
|
970
1162
|
|
|
1163
|
+
async add_assignee({ task_id, user_id }) {
|
|
1164
|
+
// Check first so an already-assigned (or already-active) user is never
|
|
1165
|
+
// re-POSTed: the REST upsert overwrites is_active on conflict, and a
|
|
1166
|
+
// blind re-assign would silently stop that person's running timer. See
|
|
1167
|
+
// POST /api/tasks/:id/assignments' ON CONFLICT clause.
|
|
1168
|
+
const current = await apiRequest(`/tasks/${task_id}/assignments`);
|
|
1169
|
+
const assigned = current.assigned || [];
|
|
1170
|
+
const active = current.active_workers || [];
|
|
1171
|
+
const existing = [...assigned, ...active].find(a => a.user_id === user_id);
|
|
1172
|
+
|
|
1173
|
+
if (existing) {
|
|
1174
|
+
const state = active.some(a => a.user_id === user_id) ? 'actively working' : 'assigned';
|
|
1175
|
+
return {
|
|
1176
|
+
content: [
|
|
1177
|
+
{
|
|
1178
|
+
type: 'text',
|
|
1179
|
+
text: `${user_id} is already ${state} on task ${task_id} — no change made. Other assignees are untouched.`,
|
|
1180
|
+
},
|
|
1181
|
+
],
|
|
1182
|
+
};
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
await apiRequest(`/tasks/${task_id}/assignments`, {
|
|
1186
|
+
method: 'POST',
|
|
1187
|
+
body: JSON.stringify({ user_id }),
|
|
1188
|
+
});
|
|
1189
|
+
|
|
1190
|
+
return {
|
|
1191
|
+
content: [
|
|
1192
|
+
{
|
|
1193
|
+
type: 'text',
|
|
1194
|
+
text: `Assigned ${user_id} to task ${task_id}. Existing assignees, if any, were left in place.`,
|
|
1195
|
+
},
|
|
1196
|
+
],
|
|
1197
|
+
};
|
|
1198
|
+
},
|
|
1199
|
+
|
|
1200
|
+
async remove_assignee({ task_id, user_id }) {
|
|
1201
|
+
await apiRequest(`/tasks/${task_id}/assignments?user_id=${encodeURIComponent(user_id)}`, {
|
|
1202
|
+
method: 'DELETE',
|
|
1203
|
+
});
|
|
1204
|
+
|
|
1205
|
+
return {
|
|
1206
|
+
content: [
|
|
1207
|
+
{
|
|
1208
|
+
type: 'text',
|
|
1209
|
+
text: `Removed ${user_id} from task ${task_id}'s assignees. Other assignees are untouched.`,
|
|
1210
|
+
},
|
|
1211
|
+
],
|
|
1212
|
+
};
|
|
1213
|
+
},
|
|
1214
|
+
|
|
1215
|
+
async verify_task({ task_id, pr_url, work_description }) {
|
|
1216
|
+
const body = {};
|
|
1217
|
+
if (pr_url !== undefined) body.pr_url = pr_url;
|
|
1218
|
+
if (work_description !== undefined) body.work_description = work_description;
|
|
1219
|
+
|
|
1220
|
+
const result = await apiRequest(`/tasks/${task_id}/verify`, {
|
|
1221
|
+
method: 'POST',
|
|
1222
|
+
body: JSON.stringify(body),
|
|
1223
|
+
});
|
|
1224
|
+
|
|
1225
|
+
const billingNote = result.billing?.type === 'ai_credit_overage'
|
|
1226
|
+
? `Billed ${result.billing.credits_charged} AI credit(s) as overage (over your plan's included verification cap).`
|
|
1227
|
+
: 'Free platform feature — within your plan\'s included verification cap.';
|
|
1228
|
+
|
|
1229
|
+
return {
|
|
1230
|
+
content: [
|
|
1231
|
+
{
|
|
1232
|
+
type: 'text',
|
|
1233
|
+
text: `Verification score for task ${task_id}: ${result.score} (confidence: ${result.confidence}). ${billingNote} ` +
|
|
1234
|
+
`This grades the PR diff against the acceptance criteria, not the running system.\n\n${JSON.stringify(result, null, 2)}`,
|
|
1235
|
+
},
|
|
1236
|
+
],
|
|
1237
|
+
};
|
|
1238
|
+
},
|
|
1239
|
+
|
|
1240
|
+
async record_verification_feedback({ task_id, agreed, actual_score, notes }) {
|
|
1241
|
+
const body = { agreed };
|
|
1242
|
+
if (actual_score !== undefined) body.actual_score = actual_score;
|
|
1243
|
+
if (notes !== undefined) body.notes = notes;
|
|
1244
|
+
|
|
1245
|
+
await apiRequest(`/tasks/${task_id}/verify-feedback`, {
|
|
1246
|
+
method: 'POST',
|
|
1247
|
+
body: JSON.stringify(body),
|
|
1248
|
+
});
|
|
1249
|
+
|
|
1250
|
+
return {
|
|
1251
|
+
content: [
|
|
1252
|
+
{
|
|
1253
|
+
type: 'text',
|
|
1254
|
+
text: `Feedback recorded for task ${task_id}'s most recent AI verification (agreed: ${agreed}).`,
|
|
1255
|
+
},
|
|
1256
|
+
],
|
|
1257
|
+
};
|
|
1258
|
+
},
|
|
1259
|
+
|
|
971
1260
|
async log_time({ task_id, minutes, notes, date }) {
|
|
972
1261
|
const entry = await apiRequest(`/tasks/${task_id}/time`, {
|
|
973
1262
|
method: 'POST',
|
|
@@ -1051,11 +1340,36 @@ const toolHandlers = {
|
|
|
1051
1340
|
},
|
|
1052
1341
|
|
|
1053
1342
|
async start_working({ task_id }) {
|
|
1343
|
+
// Presence first, and unchanged: this is what writes the `started_working`
|
|
1344
|
+
// task_events row and promotes a todo task to in_progress. The timed session
|
|
1345
|
+
// below sits ALONGSIDE that audit trail, it does not replace it.
|
|
1054
1346
|
await apiRequest(`/tasks/${task_id}/assignments`, {
|
|
1055
1347
|
method: 'POST',
|
|
1056
1348
|
body: JSON.stringify({ is_active: true }),
|
|
1057
1349
|
});
|
|
1058
1350
|
|
|
1351
|
+
// Open (or resume, or report) the work session. Non-fatal by design: the
|
|
1352
|
+
// presence write already succeeded, and a session failure must not make
|
|
1353
|
+
// start_working look like it did nothing.
|
|
1354
|
+
let timing = '';
|
|
1355
|
+
try {
|
|
1356
|
+
const result = await apiRequest(`/tasks/${task_id}/work-session`, {
|
|
1357
|
+
method: 'POST',
|
|
1358
|
+
body: JSON.stringify({}),
|
|
1359
|
+
});
|
|
1360
|
+
const s = result.session || {};
|
|
1361
|
+
if (result.action === 'resumed') {
|
|
1362
|
+
const p = result.resumed_pause || {};
|
|
1363
|
+
timing = `\n\nResumed work session #${s.id}, open since ${s.started_at} — the ${p.reason} pause that began at ${p.paused_at} is now closed. No second session was opened.`;
|
|
1364
|
+
} else if (result.action === 'already_running') {
|
|
1365
|
+
timing = `\n\nWork session #${s.id} was ALREADY running (started ${s.started_at}, ${s.worked_minutes} min worked so far). No second session was opened.`;
|
|
1366
|
+
} else {
|
|
1367
|
+
timing = `\n\nWork session #${s.id} opened at ${s.started_at} (UTC). Close it with stop_working; if you have to wait on something, use pause_working so the reason is recorded.`;
|
|
1368
|
+
}
|
|
1369
|
+
} catch (error) {
|
|
1370
|
+
timing = `\n\n(Work session NOT recorded: ${error.message} — your presence flag is set, but no duration will be measured for this stretch.)`;
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1059
1373
|
// Orient a cold-starting session: surface the resume context + recent work notes.
|
|
1060
1374
|
let orientation = '';
|
|
1061
1375
|
try {
|
|
@@ -1088,7 +1402,7 @@ const toolHandlers = {
|
|
|
1088
1402
|
content: [
|
|
1089
1403
|
{
|
|
1090
1404
|
type: 'text',
|
|
1091
|
-
text: `Started working on task #${task_id}. Other team members can now see you are actively working on this task.${orientation}`,
|
|
1405
|
+
text: `Started working on task #${task_id}. Other team members can now see you are actively working on this task.${timing}${orientation}`,
|
|
1092
1406
|
},
|
|
1093
1407
|
],
|
|
1094
1408
|
};
|
|
@@ -1111,6 +1425,42 @@ const toolHandlers = {
|
|
|
1111
1425
|
captured.push('resume context');
|
|
1112
1426
|
}
|
|
1113
1427
|
|
|
1428
|
+
// Close the session BEFORE dropping presence, so a task with no open
|
|
1429
|
+
// session fails here and nothing at all is changed.
|
|
1430
|
+
//
|
|
1431
|
+
// A missing session is a real answer, not an infrastructure hiccup: it means
|
|
1432
|
+
// the caller believes it was timing work that was never timed. Report it and
|
|
1433
|
+
// stop, rather than writing a session row with no start or silently clearing
|
|
1434
|
+
// the flag as if a duration had been recorded. Any OTHER session failure
|
|
1435
|
+
// (the endpoint being unreachable, say) must not strand the presence flag,
|
|
1436
|
+
// so that one is reported and the stop continues.
|
|
1437
|
+
let timing = '';
|
|
1438
|
+
let sessionError = null;
|
|
1439
|
+
try {
|
|
1440
|
+
const result = await apiRequest(`/tasks/${task_id}/work-session`, {
|
|
1441
|
+
method: 'DELETE',
|
|
1442
|
+
body: JSON.stringify({ state: 'completed' }),
|
|
1443
|
+
});
|
|
1444
|
+
const s = result.session || {};
|
|
1445
|
+
const paused = Number(s.paused_minutes || 0);
|
|
1446
|
+
const pausedText = paused > 0
|
|
1447
|
+
? ` (${s.elapsed_minutes} min elapsed, ${paused} min of that paused)`
|
|
1448
|
+
: '';
|
|
1449
|
+
timing = ` Work session #${s.id} closed at ${s.ended_at} — ${s.worked_minutes} minutes worked${pausedText}.`;
|
|
1450
|
+
} catch (error) {
|
|
1451
|
+
sessionError = error;
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
if (sessionError?.code === 'NO_OPEN_SESSION') {
|
|
1455
|
+
throw new Error(
|
|
1456
|
+
`${sessionError.message} Nothing was changed: no session row was written and ` +
|
|
1457
|
+
'your active-work flag was left as it was.'
|
|
1458
|
+
);
|
|
1459
|
+
}
|
|
1460
|
+
if (sessionError) {
|
|
1461
|
+
timing = ` (Work session NOT closed: ${sessionError.message})`;
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1114
1464
|
await apiRequest(`/tasks/${task_id}/assignments?stop_only=true`, {
|
|
1115
1465
|
method: 'DELETE',
|
|
1116
1466
|
});
|
|
@@ -1120,7 +1470,51 @@ const toolHandlers = {
|
|
|
1120
1470
|
content: [
|
|
1121
1471
|
{
|
|
1122
1472
|
type: 'text',
|
|
1123
|
-
text: `Stopped working on task #${task_id}. You are still assigned to the task but no longer marked as actively working.${capturedText}`,
|
|
1473
|
+
text: `Stopped working on task #${task_id}. You are still assigned to the task but no longer marked as actively working.${timing}${capturedText}`,
|
|
1474
|
+
},
|
|
1475
|
+
],
|
|
1476
|
+
};
|
|
1477
|
+
},
|
|
1478
|
+
|
|
1479
|
+
async pause_working({ task_id, reason, detail }) {
|
|
1480
|
+
const result = await apiRequest(`/tasks/${task_id}/work-session/pause`, {
|
|
1481
|
+
method: 'POST',
|
|
1482
|
+
body: JSON.stringify({ reason, note: detail }),
|
|
1483
|
+
});
|
|
1484
|
+
const p = result.pause || {};
|
|
1485
|
+
const s = result.session || {};
|
|
1486
|
+
const counted = result.counts_as_worked
|
|
1487
|
+
? 'This interval WILL count as worked time.'
|
|
1488
|
+
: 'This interval will NOT count as worked time.';
|
|
1489
|
+
return {
|
|
1490
|
+
content: [
|
|
1491
|
+
{
|
|
1492
|
+
type: 'text',
|
|
1493
|
+
text:
|
|
1494
|
+
`Paused work session #${s.id} on task #${task_id} at ${p.paused_at} (reason: ${reason}). ` +
|
|
1495
|
+
`${counted} ${s.worked_minutes} minutes worked so far. ` +
|
|
1496
|
+
'The session is still open — call resume_working when you are back.',
|
|
1497
|
+
},
|
|
1498
|
+
],
|
|
1499
|
+
};
|
|
1500
|
+
},
|
|
1501
|
+
|
|
1502
|
+
async resume_working({ task_id }) {
|
|
1503
|
+
const result = await apiRequest(`/tasks/${task_id}/work-session/resume`, {
|
|
1504
|
+
method: 'POST',
|
|
1505
|
+
body: JSON.stringify({}),
|
|
1506
|
+
});
|
|
1507
|
+
const p = result.resumed_pause || {};
|
|
1508
|
+
const s = result.session || {};
|
|
1509
|
+
return {
|
|
1510
|
+
content: [
|
|
1511
|
+
{
|
|
1512
|
+
type: 'text',
|
|
1513
|
+
text:
|
|
1514
|
+
`Resumed work session #${s.id} on task #${task_id} at ${p.resumed_at}. ` +
|
|
1515
|
+
`The ${p.reason} pause ran ${p.minutes} minutes and ` +
|
|
1516
|
+
`${p.counts_as_worked ? 'COUNTS as worked time' : 'is EXCLUDED from worked time'}. ` +
|
|
1517
|
+
`${s.worked_minutes} minutes worked on this session so far.`,
|
|
1124
1518
|
},
|
|
1125
1519
|
],
|
|
1126
1520
|
};
|